Coverage

40.8
1968
18140
1164

lib/klepsidra.ex

0.0
0
0
0
Line Hits Source
0 defmodule Klepsidra do
1 @moduledoc """
2 Klepsidra keeps the contexts that define your domain
3 and business logic.
4
5 Contexts are also responsible for managing your data, regardless
6 if it comes from the database, an external API or others.
7 """
8 end

lib/klepsidra/accounts.ex

100.0
6
48
0
Line Hits Source
0 defmodule Klepsidra.Accounts do
1 @moduledoc """
2 The Accounts context.
3 """
4
5 import Ecto.Query, warn: false
6 alias Klepsidra.Repo
7
8 alias Klepsidra.Accounts.User
9
10 @doc """
11 Returns the list of users.
12
13 ## Examples
14
15 iex> list_users()
16 [%User{}, ...]
17
18 """
19 def list_users do
20 9 Repo.all(User)
21 end
22
23 @doc """
24 Gets a single user.
25
26 Raises `Ecto.NoResultsError` if the User does not exist.
27
28 ## Examples
29
30 iex> get_user!(123)
31 %User{}
32
33 iex> get_user!(456)
34 ** (Ecto.NoResultsError)
35
36 """
37 11 def get_user!(id), do: Repo.get!(User, id)
38
39 @doc """
40 Creates a user.
41
42 ## Examples
43
44 iex> create_user(%{field: value})
45 {:ok, %User{}}
46
47 iex> create_user(%{field: bad_value})
48 {:error, %Ecto.Changeset{}}
49
50 """
51 def create_user(attrs \\ %{}) do
52 %User{}
53 |> User.changeset(attrs)
54 15 |> Repo.insert()
55 end
56
57 @doc """
58 Updates a user.
59
60 ## Examples
61
62 iex> update_user(user, %{field: new_value})
63 {:ok, %User{}}
64
65 iex> update_user(user, %{field: bad_value})
66 {:error, %Ecto.Changeset{}}
67
68 """
69 def update_user(%User{} = user, attrs) do
70 user
71 |> User.changeset(attrs)
72 4 |> Repo.update()
73 end
74
75 @doc """
76 Deletes a user.
77
78 ## Examples
79
80 iex> delete_user(user)
81 {:ok, %User{}}
82
83 iex> delete_user(user)
84 {:error, %Ecto.Changeset{}}
85
86 """
87 def delete_user(%User{} = user) do
88 2 Repo.delete(user)
89 end
90
91 @doc """
92 Returns an `%Ecto.Changeset{}` for tracking user changes.
93
94 ## Examples
95
96 iex> change_user(user)
97 %Ecto.Changeset{data: %User{}}
98
99 """
100 def change_user(%User{} = user, attrs \\ %{}) do
101 7 User.changeset(user, attrs)
102 end
103 end

lib/klepsidra/accounts/user.ex

100.0
2
269
0
Line Hits Source
0 defmodule Klepsidra.Accounts.User do
1 @moduledoc """
2 Define a schema for the `User` entity, recording authorised system users,
3 for authorisation, ownership, auditing and logging purposes.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 @primary_key {:id, Ecto.UUID, autogenerate: true}
10 @foreign_key_type Ecto.UUID
11
12 @type t :: %__MODULE__{
13 user_name: String.t(),
14 login_email: String.t(),
15 password_hash: String.t(),
16 description: String.t(),
17 frozen: boolean(),
18 active: boolean()
19 }
20 243 schema "users" do
21 field :user_name, :string
22 field :login_email, :string
23 field :password_hash, :string
24 field :description, :string
25 field :frozen, :boolean, default: false
26 field :active, :boolean, default: true
27
28 timestamps()
29 end
30
31 @doc false
32 def changeset(user, attrs) do
33 user
34 |> cast(attrs, [:user_name, :login_email, :password_hash, :frozen, :active])
35 |> validate_required([:user_name, :login_email, :password_hash])
36 |> unique_constraint(:user_name,
37 name: :users_user_name_index,
38 message: "This user name is taken"
39 )
40 26 |> unique_constraint(:login_email,
41 name: :users_login_email_index,
42 message: "This login email has already been registered"
43 )
44 end
45 end

lib/klepsidra/application.ex

75.0
4
3
1
Line Hits Source
0 defmodule Klepsidra.Application do
1 # See https://hexdocs.pm/elixir/Application.html
2 # for more information on OTP Applications
3 @moduledoc false
4
5 use Application
6
7 @impl true
8 def start(_type, _args) do
9 1 children = [
10 # Start the Telemetry supervisor
11 KlepsidraWeb.Telemetry,
12 # Start the Ecto repository
13 Klepsidra.Repo,
14 # Start the PubSub system
15 {Phoenix.PubSub, name: Klepsidra.PubSub},
16 # Start Finch
17 {Finch, name: Klepsidra.Finch},
18 # Start the Endpoint (http/https)
19 KlepsidraWeb.Endpoint
20 # Start a worker by calling: Klepsidra.Worker.start_link(arg)
21 # {Klepsidra.Worker, arg}
22 # Place
23 ]
24
25 # See https://hexdocs.pm/elixir/Supervisor.html
26 # for other strategies and supported options
27 1 opts = [strategy: :one_for_one, name: Klepsidra.Supervisor]
28 1 Supervisor.start_link(children, opts)
29 end
30
31 # Tell Phoenix to update the endpoint configuration
32 # whenever the application is updated.
33 @impl true
34 def config_change(changed, _new, removed) do
35 0 KlepsidraWeb.Endpoint.config_change(changed, removed)
36 :ok
37 end
38 end

lib/klepsidra/business_partners.ex

44.4
18
52
10
Line Hits Source
0 defmodule Klepsidra.BusinessPartners do
1 @moduledoc """
2 The BusinessPartners context.
3 """
4
5 import Ecto.Query, warn: false
6 alias Klepsidra.Repo
7
8 alias Klepsidra.BusinessPartners.BusinessPartner
9
10 @doc """
11 Returns the list of business_partners.
12
13 ## Examples
14
15 iex> list_business_partners()
16 [%BusinessPartner{}, ...]
17
18 """
19 def list_business_partners do
20 9 BusinessPartner |> order_by(asc: fragment("name COLLATE NOCASE")) |> Repo.all()
21 end
22
23 @doc """
24 Returns the list of customers
25
26 ## Examples
27
28 iex> list_customers()
29 [%BusinessPartner{}, ...]
30
31 """
32 def list_customers do
33 BusinessPartner
34 |> where(customer: true)
35 0 |> order_by(asc: fragment("name COLLATE NOCASE"))
36 0 |> Repo.all()
37 end
38
39 @doc """
40 Returns the list of 'active' business_partners.
41
42 ## Examples
43
44 iex> list_active_business_partners()
45 [%BusinessPartner{}, ...]
46
47 """
48 def list_active_business_partners do
49 BusinessPartner
50 |> where(active: true)
51 0 |> order_by(asc: fragment("name COLLATE NOCASE"))
52 0 |> Repo.all()
53 end
54
55 @doc """
56 Returns the list of customers who are active, and whose account is not
57 frozen.
58
59 ## Examples
60
61 iex> list_active_customers()
62 [%BusinessPartner{}, ...]
63
64 """
65 def list_active_customers do
66 BusinessPartner
67 |> where(customer: true, frozen: false, active: true)
68 2 |> order_by(asc: fragment("name COLLATE NOCASE"))
69 2 |> Repo.all()
70 end
71
72 @doc """
73 Gets a single business_partner.
74
75 Raises `Ecto.NoResultsError` if the Business partner does not exist.
76
77 ## Examples
78
79 iex> get_business_partner!(123)
80 %BusinessPartner{}
81
82 iex> get_business_partner!(456)
83 ** (Ecto.NoResultsError)
84
85 """
86 11 def get_business_partner!(id), do: Repo.get!(BusinessPartner, id)
87
88 @doc """
89 Creates a business_partner.
90
91 ## Examples
92
93 iex> create_business_partner(%{field: value})
94 {:ok, %BusinessPartner{}}
95
96 iex> create_business_partner(%{field: bad_value})
97 {:error, %Ecto.Changeset{}}
98
99 """
100 def create_business_partner(attrs \\ %{}) do
101 %BusinessPartner{}
102 |> BusinessPartner.changeset(attrs)
103 15 |> Repo.insert()
104 end
105
106 @doc """
107 Updates a business_partner.
108
109 ## Examples
110
111 iex> update_business_partner(business_partner, %{field: new_value})
112 {:ok, %BusinessPartner{}}
113
114 iex> update_business_partner(business_partner, %{field: bad_value})
115 {:error, %Ecto.Changeset{}}
116
117 """
118 def update_business_partner(%BusinessPartner{} = business_partner, attrs) do
119 business_partner
120 |> BusinessPartner.changeset(attrs)
121 4 |> Repo.update()
122 end
123
124 @doc """
125 Deletes a business_partner.
126
127 ## Examples
128
129 iex> delete_business_partner(business_partner)
130 {:ok, %BusinessPartner{}}
131
132 iex> delete_business_partner(business_partner)
133 {:error, %Ecto.Changeset{}}
134
135 """
136 def delete_business_partner(%BusinessPartner{} = business_partner) do
137 2 Repo.delete(business_partner)
138 end
139
140 @doc """
141 Returns an `%Ecto.Changeset{}` for tracking business_partner changes.
142
143 ## Examples
144
145 iex> change_business_partner(business_partner)
146 %Ecto.Changeset{data: %BusinessPartner{}}
147
148 """
149 def change_business_partner(%BusinessPartner{} = business_partner, attrs \\ %{}) do
150 7 BusinessPartner.changeset(business_partner, attrs)
151 end
152
153 alias Klepsidra.BusinessPartners.Note
154
155 @doc """
156 Returns the list of business_partner_notes.
157
158 ## Examples
159
160 iex> list_business_partner_notes()
161 [%Note{}, ...]
162
163 """
164 def list_business_partner_notes do
165 0 Repo.all(Note)
166 end
167
168 @doc """
169 Gets a single note.
170
171 Raises `Ecto.NoResultsError` if the Note does not exist.
172
173 ## Examples
174
175 iex> get_note!(123)
176 %Note{}
177
178 iex> get_note!(456)
179 ** (Ecto.NoResultsError)
180
181 """
182 0 def get_note!(id), do: Repo.get!(Note, id)
183
184 @doc """
185 Creates a note.
186
187 ## Examples
188
189 iex> create_note(%{field: value})
190 {:ok, %Note{}}
191
192 iex> create_note(%{field: bad_value})
193 {:error, %Ecto.Changeset{}}
194
195 """
196 def create_note(attrs \\ %{}) do
197 %Note{}
198 |> Note.changeset(attrs)
199 0 |> Repo.insert()
200 end
201
202 @doc """
203 Updates a note.
204
205 ## Examples
206
207 iex> update_note(note, %{field: new_value})
208 {:ok, %Note{}}
209
210 iex> update_note(note, %{field: bad_value})
211 {:error, %Ecto.Changeset{}}
212
213 """
214 def update_note(%Note{} = note, attrs) do
215 note
216 |> Note.changeset(attrs)
217 0 |> Repo.update()
218 end
219
220 @doc """
221 Deletes a note.
222
223 ## Examples
224
225 iex> delete_note(note)
226 {:ok, %Note{}}
227
228 iex> delete_note(note)
229 {:error, %Ecto.Changeset{}}
230
231 """
232 def delete_note(%Note{} = note) do
233 0 Repo.delete(note)
234 end
235
236 @doc """
237 Returns an `%Ecto.Changeset{}` for tracking note changes.
238
239 ## Examples
240
241 iex> change_note(note)
242 %Ecto.Changeset{data: %Note{}}
243
244 """
245 def change_note(%Note{} = note, attrs \\ %{}) do
246 0 Note.changeset(note, attrs)
247 end
248 end

lib/klepsidra/business_partners/business_partner.ex

75.0
4
309
1
Line Hits Source
0 defmodule Klepsidra.BusinessPartners.BusinessPartner do
1 @moduledoc """
2 Defines a schema for the `Business Partners` entity, recording customers and
3 suppliers of the busines.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 @primary_key {:id, Ecto.UUID, autogenerate: true}
10 @foreign_key_type Ecto.UUID
11
12 @type t :: %__MODULE__{
13 name: String.t(),
14 description: String.t(),
15 default_currency: String.t(),
16 customer: boolean(),
17 supplier: boolean(),
18 frozen: boolean(),
19 active: boolean()
20 }
21 281 schema "business_partners" do
22 field :name, :string
23 field :description, :string
24 field :default_currency, :string
25 field :customer, :boolean, default: false
26 field :supplier, :boolean, default: false
27 field :frozen, :boolean, default: false
28 field :active, :boolean, default: true
29
30 timestamps()
31 end
32
33 @doc false
34 def changeset(business_partner, attrs) do
35 business_partner
36 |> cast(attrs, [:name, :description, :customer, :supplier, :active])
37 |> validate_required([:name], message: "Enter a customer name")
38 26 |> unique_constraint(:name,
39 name: :business_partners_name_index,
40 message: "A customer with this name already exists"
41 )
42 end
43
44 @doc """
45 Used across live components to populate select options with projects.
46 """
47 @spec populate_customers_list() :: [Klepsidra.BusinessPartners.BusinessPartner.t(), ...]
48 2 def populate_customers_list() do
49 [
50 {"", ""}
51 | Klepsidra.BusinessPartners.list_active_customers()
52 0 |> Enum.map(fn bp -> {bp.name, bp.id} end)
53 ]
54 end
55 end

lib/klepsidra/business_partners/note.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.BusinessPartners.Note do
1 @moduledoc """
2 Defines the data schema for the business partners`Note` entity,
3 annotations of business partners.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 @primary_key {:id, Ecto.UUID, autogenerate: true}
10 @foreign_key_type Ecto.UUID
11
12 @type t :: %__MODULE__{
13 note: String.t(),
14 business_partner_id: binary()
15 }
16 0 schema "business_partner_notes" do
17 field :note, :string
18 belongs_to :business_partner, BusinessPartner, type: Ecto.UUID
19
20 timestamps()
21 end
22
23 @doc false
24 def changeset(note, attrs) do
25 note
26 |> cast(attrs, [:note, :business_partner_id])
27 |> validate_required([:note], message: "The message can't be empty")
28 0 |> assoc_constraint(:business_partner)
29 end
30 end

lib/klepsidra/categorisation.ex

10.0
60
48
54
Line Hits Source
0 defmodule Klepsidra.Categorisation do
1 @moduledoc """
2 The Categorisation context provides a way to categorise entities within the
3 application.
4
5 A general-purpose _tagging_ module provides a record of all tags used for various entities.
6 Presently, tagging is only used in activity timers so that users can simply categorise
7 their timed activities, to help filter activities by category, to search for timers, and to
8 make it easier to collate timers with client invoicing in mind.
9
10 Tagging activity timers requires a many-to-many relationship between timers and
11 tags, which is recorded in the `timer_tags` table.
12 """
13
14 import Ecto.Query, warn: false
15 alias Klepsidra.Repo
16
17 alias Klepsidra.Categorisation.Tag
18 alias Klepsidra.Categorisation.TimerTags
19 alias Klepsidra.Categorisation.ProjectTags
20 alias Klepsidra.Categorisation.JournalEntryTags
21
22 @doc """
23 Returns the list of tags.
24
25 ## Examples
26
27 iex> list_tags()
28 [%Tag{}, ...]
29
30 """
31 def list_tags do
32 9 Tag |> order_by(asc: fragment("name COLLATE NOCASE")) |> Repo.all()
33 end
34
35 @doc """
36 Gets a single tag.
37
38 Raises `Ecto.NoResultsError` if the Tag does not exist.
39
40 ## Examples
41
42 iex> get_tag!(123)
43 %Tag{}
44
45 iex> get_tag!(456)
46 ** (Ecto.NoResultsError)
47
48 """
49 11 def get_tag!(id), do: Repo.get!(Tag, id)
50
51 @doc """
52 Gets multiple tags.
53
54 Raises `Ecto.NoResultsError` if the Tag id is not a proper UUID.
55
56 ## Examples
57
58 iex> get_tags!([123, 789])
59 %Tag{}
60
61 iex> get_tag!([])
62 []
63
64 iex> get_tag!([""])
65 ** (Ecto.Query.CastError)
66
67 """
68 @spec get_tags!(id_list :: [Ecto.UUID.t(), ...]) :: [Tag.t(), ...] | []
69 def get_tags!(id_list) when is_list(id_list) do
70 0 Repo.all(from(t in Tag, where: t.id in ^id_list))
71 end
72
73 @doc """
74 Gets multiple tags, sorted by tag name.
75
76 Raises `Ecto.NoResultsError` if the Tag id is not a proper UUID.
77
78 ## Examples
79
80 iex> get_tags_sorted!([123, 789])
81 %Tag{}
82
83 iex> get_tag_sorted!([])
84 []
85
86 iex> get_tag_sorted!([""])
87 ** (Ecto.Query.CastError)
88
89 """
90 @spec get_tags_sorted!(id_list :: [Ecto.UUID.t(), ...]) :: [Tag.t(), ...] | []
91 def get_tags_sorted!(id_list) when is_list(id_list) do
92 0 Repo.all(
93 from(
94 t in Tag,
95 where: t.id in ^id_list,
96 order_by: [asc: t.name]
97 )
98 )
99 end
100
101 @doc """
102 Simple search for tags defined in the system, performing a prefix filter only.
103
104 This search takes in the `search_phrase`, and after converting it to lowercase,
105 compares it against a list of similarly lowercased tag names (`name` field), from the
106 database. The comparison checks filters all tags that start with the normalised
107 search phrase.
108
109 ## Examples
110
111 iex> search_tags_by_name_prefix("hello")
112 [%Tag{}, ...]
113 """
114 @spec search_tags_by_name_prefix(String.t()) :: [Tag.t(), ...]
115 def search_tags_by_name_prefix(search_phrase) do
116 0 search_phrase = String.downcase(search_phrase)
117
118 Klepsidra.Categorisation.list_tags()
119 0 |> Enum.filter(fn %{name: name} ->
120 0 String.starts_with?(String.downcase(name), search_phrase)
121 end)
122 end
123
124 @doc """
125 Search for tags using a `LIKE` wildcard search.
126 """
127 @spec search_tags_by_name_content(search_phrase :: String.t()) :: [Tag.t(), ...]
128 def search_tags_by_name_content(search_phrase) when is_bitstring(search_phrase) do
129 0 search_fragment = "%#{String.downcase(search_phrase)}%"
130
131 0 query =
132 from(t in Tag,
133 where: like(t.name, ^search_fragment),
134 order_by: [asc: fragment("lower(?)", t.name)]
135 )
136
137 0 Repo.all(query)
138 end
139
140 @doc """
141 Creates a tag.
142
143 ## Examples
144
145 iex> create_tag(%{field: value})
146 {:ok, %Tag{}}
147
148 iex> create_tag(%{field: bad_value})
149 {:error, %Ecto.Changeset{}}
150
151 """
152 @spec create_tag(attrs :: map()) :: {:ok, Tag.t()} | {:error, any()}
153 def create_tag(attrs \\ %{}) do
154 %Tag{}
155 |> Tag.changeset(attrs)
156 15 |> Repo.insert()
157 end
158
159 @doc """
160 Creates a tag if it doesn't exist, otherwise gets and returns a tag
161 matching the name provided.
162
163 ## Examples
164
165 iex> create_or_find_tag(%{name: good_name})
166 %Tag{}
167
168 iex> create_or_find_tag(%{field: existing_name})
169 %Tag{}
170
171 """
172 @spec create_or_find_tag(attrs :: map()) :: Tag.t()
173 def create_or_find_tag(%{name: "" <> name} = attrs) do
174 %Tag{}
175 |> Tag.changeset(attrs)
176 |> Repo.insert()
177 0 |> case do
178 {:ok, tag} ->
179 0 tag
180
181 {:error, _changeset} ->
182 0 Repo.get_by(Tag, name: name)
183
184 _ ->
185 0 Repo.get_by(Tag, name: name)
186 end
187 end
188
189 0 def create_or_find_tag(_), do: nil
190
191 @doc """
192 Updates a tag.
193
194 ## Examples
195
196 iex> update_tag(tag, %{field: new_value})
197 {:ok, %Tag{}}
198
199 iex> update_tag(tag, %{field: bad_value})
200 {:error, %Ecto.Changeset{}}
201
202 """
203 def update_tag(%Tag{} = tag, attrs) do
204 tag
205 |> Tag.changeset(attrs)
206 4 |> Repo.update()
207 end
208
209 @doc """
210 Deletes a tag.
211
212 ## Examples
213
214 iex> delete_tag(tag)
215 {:ok, %Tag{}}
216
217 iex> delete_tag(tag)
218 {:error, %Ecto.Changeset{}}
219
220 """
221 def delete_tag(%Tag{} = tag) do
222 2 Repo.delete(tag)
223 end
224
225 @doc """
226 Returns an `%Ecto.Changeset{}` for tracking tag changes.
227
228 ## Examples
229
230 iex> change_tag(tag)
231 %Ecto.Changeset{data: %Tag{}}
232
233 """
234 def change_tag(%Tag{} = tag, attrs \\ %{}) do
235 7 Tag.changeset(tag, attrs)
236 end
237
238 @doc """
239 Attach a single tag to a timer. Checks if the tag is already associated
240 with the timer, only adding it if it’s missing.
241
242 ## Examples
243
244 iex> add_timer_tag(%Timer{}, %Tag{})
245 {:ok, :inserted}
246
247 iex> add_timer_tag(%Timer{}, %Tag{})
248 {:ok, :already_exists}
249
250 iex> add_timer_tag(%Timer{}, %Tag{})
251 {:error, :insert_failed}
252
253 """
254 @spec add_timer_tag(timer_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) ::
255 {:ok, :inserted}
256 | {:ok, :already_exists}
257 | {:error, :insert_failed}
258 | {:error, :timer_is_nil}
259 0 def add_timer_tag(nil, _tag_id), do: {:error, :timer_is_nil}
260
261 def add_timer_tag(timer_id, tag_id) do
262 0 now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
263
264 # Check if the tag is already associated with the timer
265 0 existing_association =
266 Repo.get_by(TimerTags, timer_id: timer_id, tag_id: tag_id)
267
268 # Repo.update(changeset)
269 0 if existing_association do
270 {:ok, :already_exists}
271 else
272 # Insert a new association with timestamps
273 0 timer_tag_entry = %TimerTags{
274 timer_id: timer_id,
275 tag_id: tag_id,
276 inserted_at: now,
277 updated_at: now
278 }
279
280 0 case Repo.insert(timer_tag_entry) do
281 0 {:ok, _} -> {:ok, :inserted}
282 0 {:error, _} -> {:error, :insert_failed}
283 end
284 end
285 end
286
287 @doc """
288 Deletes a timer's tag association.
289
290 ## Examples
291
292 iex> delete_timer_tag(%Timer(), %Tag())
293 {:ok, :deleted}
294
295 iex> delete_timer_tag(%Timer(), %Tag())
296 {:error, :not_found}
297
298 iex> delete_timer_tag(%Timer(), %Tag())
299 {:error, :delete_failed}
300
301 """
302 @spec delete_timer_tag(timer_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) ::
303 {:ok, :deleted} | {:error, :not_found} | {:error, :delete_failed}
304 def delete_timer_tag(timer_id, tag_id) do
305 # Execute the delete operation on the "timer_tags" table
306 0 case Repo.get_by(TimerTags, timer_id: timer_id, tag_id: tag_id) do
307 # Record not found
308 0 nil ->
309 {:error, :not_found}
310
311 timer_tag ->
312 0 case Repo.delete(timer_tag) do
313 0 {:ok, _} -> {:ok, :deleted}
314 0 {:error, _} -> {:error, :delete_failed}
315 end
316 end
317 end
318
319 @doc """
320 Gets a single timer tag record.
321
322 Raises `Ecto.NoResultsError` if the Tag does not exist.
323
324 ## Examples
325
326 iex> get_timer_tag!("timer_id", "tag_id")
327 %TimerTags{}
328
329 iex> get_timer_tag!("", "")
330 ** (Ecto.NoResultsError)
331
332 """
333 @spec get_timer_tag!(timer_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: TimerTags.t()
334 def get_timer_tag!(timer_id, tag_id),
335 0 do: Repo.get_by!(TimerTags, timer_id: timer_id, tag_id: tag_id)
336
337 @doc """
338 Attach a single tag to a project. Checks if the tag is already associated
339 with the project, only adding it if it’s missing.
340
341 ## Examples
342
343 iex> add_project_tag(%Project{}, %Tag{})
344 {:ok, :inserted}
345
346 iex> add_project_tag(%Project{}, %Tag{})
347 {:ok, :already_exists}
348
349 iex> add_project_tag(%Project{}, %Tag{})
350 {:error, :insert_failed}
351
352 """
353 @spec add_project_tag(project_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) ::
354 {:ok, :inserted}
355 | {:ok, :already_exists}
356 | {:error, :insert_failed}
357 | {:error, :project_is_nil}
358 0 def add_project_tag(nil, _tag_id), do: {:error, :project_is_nil}
359
360 def add_project_tag(project_id, tag_id) do
361 0 now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
362
363 # Check if the tag is already associated with the project
364 0 existing_association =
365 Repo.get_by(ProjectTags, project_id: project_id, tag_id: tag_id)
366
367 # Repo.update(changeset)
368 0 if existing_association do
369 {:ok, :already_exists}
370 else
371 # Insert a new association with timestamps
372 0 project_tag_entry = %ProjectTags{
373 project_id: project_id,
374 tag_id: tag_id,
375 inserted_at: now,
376 updated_at: now
377 }
378
379 0 case Repo.insert(project_tag_entry) do
380 0 {:ok, _} -> {:ok, :inserted}
381 0 {:error, _} -> {:error, :insert_failed}
382 end
383 end
384 end
385
386 @doc """
387 Deletes a project's tag association.
388
389 ## Examples
390
391 iex> delete_project_tag(%Project(), %Tag())
392 {:ok, :deleted}
393
394 iex> delete_project_tag(%Project(), %Tag())
395 {:error, :not_found}
396
397 iex> delete_project_tag(%Project(), %Tag())
398 {:error, :unexpected_result}
399
400 """
401 @spec delete_project_tag(project_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) ::
402 {:ok, :deleted} | {:error, :not_found} | {:error, :delete_failed}
403 def delete_project_tag(project_id, tag_id) do
404 # Execute the delete operation on the "project_tags" table
405 0 case Repo.get_by(ProjectTags, project_id: project_id, tag_id: tag_id) do
406 # Record not found
407 0 nil ->
408 {:error, :not_found}
409
410 project_tag ->
411 0 case Repo.delete(project_tag) do
412 0 {:ok, _} -> {:ok, :deleted}
413 0 {:error, _} -> {:error, :delete_failed}
414 end
415 end
416 end
417
418 @doc """
419 Gets a single project tag.
420
421 Raises `Ecto.NoResultsError` if the Project tag does not exist.
422
423 ## Examples
424
425 iex> get_project_tag!(123)
426 %ProjectTags{}
427
428 iex> get_project_tag!(456)
429 ** (Ecto.NoResultsError)
430
431 """
432 @spec get_project_tag!(project_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) :: ProjectTags.t()
433 def get_project_tag!(project_id, tag_id),
434 0 do: Repo.get!(ProjectTags, project_id: project_id, tag_id: tag_id)
435
436 @doc """
437 Attach a single tag to a journal entry. Checks if the tag is already associated
438 with the entry, only adding it if it’s missing.
439
440 ## Examples
441
442 iex> add_journal_entry_tag(%JournalEntry{}, %Tag{})
443 {:ok, :inserted}
444
445 iex> add_journal_entry_tag(%JournalEntry{}, %Tag{})
446 {:ok, :already_exists}
447
448 iex> add_journal_entry_tag(%JournalEntry{}, %Tag{})
449 {:error, :insert_failed}
450
451 """
452 @spec add_journal_entry_tag(journal_entry_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) ::
453 {:ok, :inserted}
454 | {:ok, :already_exists}
455 | {:error, :insert_failed}
456 | {:error, :journal_entry_is_nil}
457 0 def add_journal_entry_tag(nil, _tag_id), do: {:error, :journal_entry_is_nil}
458
459 def add_journal_entry_tag(journal_entry_id, tag_id) do
460 0 now = NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second)
461
462 # Check if the tag is already associated with the journal entry
463 0 existing_association =
464 Repo.get_by(JournalEntryTags, journal_entry_id: journal_entry_id, tag_id: tag_id)
465
466 # Repo.update(changeset)
467 0 if existing_association do
468 {:ok, :already_exists}
469 else
470 # Insert a new association with timestamps
471 0 journal_entry_tag_entry = %JournalEntryTags{
472 journal_entry_id: journal_entry_id,
473 tag_id: tag_id,
474 inserted_at: now,
475 updated_at: now
476 }
477
478 0 case Repo.insert(journal_entry_tag_entry) do
479 0 {:ok, _} -> {:ok, :inserted}
480 0 {:error, _} -> {:error, :insert_failed}
481 end
482 end
483 end
484
485 @doc """
486 Deletes a journal entry's tag association.
487
488 ## Examples
489
490 iex> delete_journal_entry_tag(%JournalEntry(), %Tag())
491 {:ok, :deleted}
492
493 iex> delete_journal_entry_tag(%JournalEntry(), %Tag())
494 {:error, :not_found}
495
496 iex> delete_journal_entry_tag(%JournalEntry(), %Tag())
497 {:error, :unexpected_result}
498
499 """
500 @spec delete_journal_entry_tag(journal_entry_id :: Ecto.UUID.t(), tag_id :: Ecto.UUID.t()) ::
501 {:ok, :deleted} | {:error, :not_found} | {:error, :delete_failed}
502 def delete_journal_entry_tag(journal_entry_id, tag_id) do
503 # Execute the delete operation on the "journal_entry_tags" table
504 0 case Repo.get_by(JournalEntryTags, journal_entry_id: journal_entry_id, tag_id: tag_id) do
505 # Record not found
506 0 nil ->
507 {:error, :not_found}
508
509 journal_entry_tag ->
510 0 case Repo.delete(journal_entry_tag) do
511 0 {:ok, _} -> {:ok, :deleted}
512 0 {:error, _} -> {:error, :delete_failed}
513 end
514 end
515 end
516 end

lib/klepsidra/categorisation/journal_entry_tags.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.Categorisation.JournalEntryTags do
1 @moduledoc """
2 Defines a schema for the `JournalEntryTags` entity, used to create a many-to-many
3 relationship between journal entries and tags.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 alias Klepsidra.Journals.JournalEntry
10 alias Klepsidra.Categorisation.Tag
11
12 @primary_key false
13 @foreign_key_type Ecto.UUID
14
15 @type t :: %__MODULE__{
16 journal_entry_id: Ecto.UUID.t(),
17 tag_id: Ecto.UUID.t()
18 }
19 0 schema "journal_entry_tags" do
20 belongs_to(:journal_entry, JournalEntry, primary_key: true, type: Ecto.UUID)
21 belongs_to(:tag, Tag, primary_key: true, type: Ecto.UUID)
22
23 timestamps()
24 end
25
26 @doc false
27 def changeset(journal_entry_tags, _attrs) do
28 journal_entry_tags
29 |> unique_constraint([:journal_entry, :tag],
30 name: "journal_entry_tags_journal_entry_id_tag_id_index",
31 message: "This tag has already been added to the journal entry"
32 )
33 0 |> cast_assoc(:tag)
34 end
35 end

lib/klepsidra/categorisation/project_tags.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.Categorisation.ProjectTags do
1 @moduledoc """
2 Defines a schema for the `ProjectTags` entity, used to create a many-to-many
3 relationship between projects and tags.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 alias Klepsidra.Projects.Project
10 alias Klepsidra.Categorisation.Tag
11
12 @primary_key false
13 @foreign_key_type Ecto.UUID
14
15 @type t :: %__MODULE__{
16 project_id: Ecto.UUID.t(),
17 tag_id: Ecto.UUID.t()
18 }
19 0 schema "project_tags" do
20 belongs_to(:project, Project, primary_key: true, type: Ecto.UUID)
21 belongs_to(:tag, Tag, primary_key: true, type: Ecto.UUID)
22
23 timestamps()
24 end
25
26 @doc false
27 def changeset(project_tags, _attrs) do
28 project_tags
29 |> unique_constraint([:project, :tag],
30 name: "project_tags_project_id_tag_id_index",
31 message: "This tag has already been added to the project"
32 )
33 0 |> cast_assoc(:tag)
34 end
35 end

lib/klepsidra/categorisation/tag.ex

14.2
14
340
12
Line Hits Source
0 defmodule Klepsidra.Categorisation.Tag do
1 @moduledoc """
2 Defines a schema for the `Tags` entity, used for categorising timed activities
3 with free form tags.
4
5 To provide a helpful flourish which will make selected tags stand out, we include a
6 `colour` field.
7 """
8
9 use Ecto.Schema
10 import Ecto.Changeset
11
12 @primary_key {:id, Ecto.UUID, autogenerate: true}
13 @foreign_key_type Ecto.UUID
14
15 @type t :: %__MODULE__{
16 name: String.t(),
17 description: String.t(),
18 colour: String.t(),
19 fg_colour: String.t()
20 }
21 314 schema "tags" do
22 field(:name, :string)
23 field(:description, :string)
24 field(:colour, :string)
25 field(:fg_colour, :string)
26
27 many_to_many(:timers, Klepsidra.TimeTracking.Timer,
28 join_through: "timer_tags",
29 on_replace: :delete,
30 preload_order: [asc: :start_stamp]
31 )
32
33 timestamps()
34 end
35
36 @doc false
37 def changeset(tag, attrs) do
38 tag
39 |> cast(attrs, [:name, :description, :colour, :fg_colour])
40 |> validate_required([:name], message: "Enter a tag name")
41 26 |> unique_constraint(:name,
42 name: :tags_name_index,
43 message: "A tag with this name already exists"
44 )
45 end
46
47 @doc """
48 Finds tag list differences between the list of applied tags and
49 those in the front-end component's accumulator list, calling
50 functions responsible for adding and removing tags.
51 """
52 @spec handle_tag_list_changes(
53 list1 :: list(),
54 list2 :: list(),
55 entity_id :: bitstring(),
56 insert_fun :: function(),
57 delete_fun :: function()
58 ) ::
59 nil
60 0 def handle_tag_list_changes([], [], _entity_id, _insert_fun, _delete_fun), do: nil
61
62 0 def handle_tag_list_changes(_list1, _list2, nil, _insert_fun, _delete_fun), do: nil
63
64 def handle_tag_list_changes(list1, list2, entity_id, insert_fun, delete_fun)
65 when is_list(list1) and is_list(list2) and is_bitstring(entity_id) do
66 0 deletion_list = list1 -- list2
67 0 insertion_list = list2 -- list1
68
69 0 handle_tag_actions(insertion_list, entity_id, insert_fun)
70
71 0 handle_tag_actions(deletion_list, entity_id, delete_fun)
72 end
73
74 @doc """
75 """
76 @spec handle_tag_actions(
77 action_list :: list(),
78 entity_id :: bitstring(),
79 insert_function :: function()
80 ) :: nil | none()
81 0 def handle_tag_actions([], _base_entity, _enumeration_function), do: nil
82
83 def handle_tag_actions({:ins, tag_id_list}, base_entity, enumeration_function),
84 0 do: handle_tag_actions(tag_id_list, base_entity, enumeration_function)
85
86 def handle_tag_actions({:del, tag_id_list}, base_entity, enumeration_function),
87 0 do: handle_tag_actions(tag_id_list, base_entity, enumeration_function)
88
89 def handle_tag_actions(tag_id_list, base_entity, enumeration_function)
90 when is_list(tag_id_list) and is_function(enumeration_function) do
91 tag_id_list
92 0 |> Enum.map(fn tag_id ->
93 0 enumeration_function.(base_entity, tag_id)
94 end)
95 end
96
97 0 def handle_tag_actions(_, _base_entity, _enumeration_function), do: nil
98 end

lib/klepsidra/categorisation/timer_tags.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.Categorisation.TimerTags do
1 @moduledoc """
2 Defines a schema for the `TimerTags` entity, used to create a many-to-many
3 relationship between timers and tags.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 alias Klepsidra.TimeTracking.Timer
10 alias Klepsidra.Categorisation.Tag
11
12 @primary_key false
13 @foreign_key_type Ecto.UUID
14
15 @type t :: %__MODULE__{
16 timer_id: Ecto.UUID.t(),
17 tag_id: Ecto.UUID.t()
18 }
19 0 schema "timer_tags" do
20 belongs_to(:timer, Timer, primary_key: true, type: Ecto.UUID)
21 belongs_to(:tag, Tag, primary_key: true, type: Ecto.UUID)
22
23 timestamps()
24 end
25
26 @doc false
27 def changeset(timer_tags, _attrs) do
28 timer_tags
29 |> unique_constraint([:timer, :tag],
30 name: "timer_tags_timer_id_tag_id_index",
31 message: "This tag has already been added to the timer"
32 )
33 0 |> cast_assoc(:tag)
34 end
35 end

lib/klepsidra/dynamic_css.ex

7.1
14
11
13
Line Hits Source
0 defmodule Klepsidra.DynamicCSS do
1 @moduledoc """
2 Generate CSS content based on your dynamic data (such as
3 tag colors), saving it to a `.css` file in the `priv/static/assets`
4 directory.
5 """
6
7 alias Klepsidra.Categorisation
8
9 @doc """
10 Generates tag styling classes for all tags, named `tag-<tag_name>`.
11 """
12 @spec generate_tag_styles(tags :: [Klepsidra.Categorisation.Tag.t(), ...] | []) :: binary()
13 def generate_tag_styles(tags) do
14 11 Enum.map_join(tags, "\n", fn tag ->
15 0 generate_tag_style_declaration(tag)
16 end)
17 end
18
19 @doc """
20 Generates a single tag style declaration.
21 """
22 @spec generate_tag_style_declaration(tag :: Klepsidra.Categorisation.Tag.t()) :: binary()
23 def generate_tag_style_declaration(tag) when is_bitstring(tag),
24 0 do: generate_tag_style_declaration(Categorisation.get_tag!(tag))
25
26 def generate_tag_style_declaration(tag) when is_struct(tag, Klepsidra.Categorisation.Tag) do
27 0 tag_class_name = convert_tag_name_to_class(tag.name)
28
29 0 fg_colour = if tag.fg_colour, do: tag.fg_colour, else: "#fff"
30
31 0 bg_colour = if tag.colour, do: tag.colour, else: "rgb(148, 163, 184)"
32
33 0 bg_colour_lowered_opacity =
34 0 if tag.colour, do: tag.colour <> "88", else: "rgba(255, 255, 255, 0.1)"
35
36 0 """
37 0 .tag-#{tag_class_name}, .tag-#{tag_class_name} + button {background-color: #{bg_colour}; color: #{fg_colour};}
38 0 .tag-#{tag_class_name} + button {border-left: 2px solid #{bg_colour_lowered_opacity}; border-left-color: oklch(from #{bg_colour} calc(l + 0.1) c h);}
39 """
40 end
41
42 @doc """
43 Converts tag names to CSS class-compliant format.
44 """
45 @spec convert_tag_name_to_class(tag_name :: binary()) :: binary()
46 def convert_tag_name_to_class(tag_name) when is_bitstring(tag_name) do
47 tag_name
48 |> String.split(" ")
49 |> Enum.map(fn item ->
50 0 String.replace(
51 item,
52 [
53 "!",
54 "£",
55 "$",
56 "%",
57 "^",
58 "&",
59 "*",
60 "(",
61 ")",
62 "[",
63 "]",
64 "{",
65 "}",
66 ":",
67 ";",
68 ",",
69 ".",
70 "/",
71 "\\",
72 "<",
73 ">"
74 ],
75 "",
76 global: true
77 )
78 end)
79 0 |> Enum.reject(fn item -> item == "" end)
80 0 |> Enum.join("_")
81 end
82 end

lib/klepsidra/ex_cldr.ex

0.0
0
0
0
Line Hits Source
0 defmodule Klepsidra.Cldr do
1 @moduledoc """
2 Defines additional time units encountered in the commercial world.
3
4 Businesses record their time units in a range of time units, or billing increments,
5 based on standard time primitives such as minutes and hours. Building on `Cldr.Units`,
6 any desired time units must be defined here before they can be used in an application,
7 since they must be compiled prior to use.
8
9 Using the hour as a guide, practically all common divisions of the hour-in minutes-are
10 defined here. This follows research carried out into time increments different industries
11 choose to use to bill their clients in.
12 """
13
14 use Cldr.Unit.Additional
15
16 use Cldr,
17 locales: ["en", "en_AU", "en_CA", "en_GB"],
18 default_locale: "en_GB",
19 providers: [Cldr.Number, Cldr.Calendar, Cldr.DateTime, Cldr.Unit, Cldr.List]
20
21 # "en" locale
22 # minute increment
23 unit_localization(:minute_increment, "en", :long,
24 nominative: %{
25 one: "{0} minute",
26 other: "{0} minutes"
27 },
28 display_name: "Minutes"
29 )
30
31 unit_localization(:minute_increment, "en", :short,
32 nominative: %{
33 one: "{0} min",
34 other: "{0} mins"
35 },
36 display_name: "Minutes"
37 )
38
39 unit_localization(:minute_increment, "en", :narrow,
40 nominative: %{
41 one: "{0} min",
42 other: "{0} mins"
43 },
44 display_name: "Minutes"
45 )
46
47 # 5 minute increment
48 unit_localization(:five_minute_increment, "en", :long,
49 nominative: %{
50 one: "{0} five minute increment",
51 other: "{0} five minute increments"
52 },
53 display_name: "5 minutes"
54 )
55
56 unit_localization(:five_minute_increment, "en", :short,
57 nominative: %{
58 one: "{0} five mins",
59 other: "{0} five mins"
60 },
61 display_name: "5 mins"
62 )
63
64 unit_localization(:five_minute_increment, "en", :narrow,
65 nominative: %{
66 one: "{0} five min",
67 other: "{0} five min"
68 },
69 display_name: "5 min"
70 )
71
72 # 6 minute increment
73 unit_localization(:six_minute_increment, "en", :long,
74 nominative: %{
75 one: "{0} six minute increment",
76 other: "{0} six minute increments"
77 },
78 display_name: "6 minutes"
79 )
80
81 unit_localization(:six_minute_increment, "en", :short,
82 nominative: %{
83 one: "{0} six mins",
84 other: "{0} six mins"
85 },
86 display_name: "6 mins"
87 )
88
89 unit_localization(:six_minute_increment, "en", :narrow,
90 nominative: %{
91 one: "{0} six min",
92 other: "{0} six min"
93 },
94 display_name: "6 min"
95 )
96
97 # 10 minute increment
98 unit_localization(:ten_minute_increment, "en", :long,
99 nominative: %{
100 one: "{0} ten minute increment",
101 other: "{0} ten minute increments"
102 },
103 display_name: "10 minute increment"
104 )
105
106 unit_localization(:ten_minute_increment, "en", :short,
107 nominative: %{
108 one: "{0} ten mins",
109 other: "{0} ten mins"
110 },
111 display_name: "10 mins"
112 )
113
114 unit_localization(:ten_minute_increment, "en", :narrow,
115 nominative: %{
116 one: "{0} ten min",
117 other: "{0} ten min"
118 },
119 display_name: "10 min"
120 )
121
122 # 12 minute increment
123 unit_localization(:twelve_minute_increment, "en", :long,
124 nominative: %{
125 one: "{0} twelve minute increment",
126 other: "{0} twelve minute increments"
127 },
128 display_name: "12 minute increment"
129 )
130
131 unit_localization(:twelve_minute_increment, "en", :short,
132 nominative: %{
133 one: "{0} twelve mins",
134 other: "{0} twelve mins"
135 },
136 display_name: "12 mins"
137 )
138
139 unit_localization(:twelve_minute_increment, "en", :narrow,
140 nominative: %{
141 one: "{0} twelve min",
142 other: "{0} twelve min"
143 },
144 display_name: "12 min"
145 )
146
147 # 15 minute increment
148 unit_localization(:fifteen_minute_increment, "en", :long,
149 nominative: %{
150 one: "{0} fifteen minute increment",
151 other: "{0} fifteen minute increments"
152 },
153 display_name: "15 minute increment"
154 )
155
156 unit_localization(:fifteen_minute_increment, "en", :short,
157 nominative: %{
158 one: "{0} fifteen mins",
159 other: "{0} fifteen mins"
160 },
161 display_name: "15 mins"
162 )
163
164 unit_localization(:fifteen_minute_increment, "en", :narrow,
165 nominative: %{
166 one: "{0} fifteen min",
167 other: "{0} fifteen min"
168 },
169 display_name: "15 min"
170 )
171
172 # 18 minute increment
173 unit_localization(:eighteen_minute_increment, "en", :long,
174 nominative: %{
175 one: "{0} eighteen minute increment",
176 other: "{0} eighteen minute increments"
177 },
178 display_name: "18 minute increment"
179 )
180
181 unit_localization(:eighteen_minute_increment, "en", :short,
182 nominative: %{
183 one: "{0} eighteen mins",
184 other: "{0} eighteen mins"
185 },
186 display_name: "18 mins"
187 )
188
189 unit_localization(:eighteen_minute_increment, "en", :narrow,
190 nominative: %{
191 one: "{0} eighteen min",
192 other: "{0} eighteen min"
193 },
194 display_name: "18 min"
195 )
196
197 # 20 minute increment
198 unit_localization(:twenty_minute_increment, "en", :long,
199 nominative: %{
200 one: "{0} twenty minute increment",
201 other: "{0} twenty minute increments"
202 },
203 display_name: "20 minute increment"
204 )
205
206 unit_localization(:twenty_minute_increment, "en", :short,
207 nominative: %{
208 one: "{0} twenty mins",
209 other: "{0} twenty mins"
210 },
211 display_name: "20 mins"
212 )
213
214 unit_localization(:twenty_minute_increment, "en", :narrow,
215 nominative: %{
216 one: "{0} twenty min",
217 other: "{0} twenty min"
218 },
219 display_name: "20 min"
220 )
221
222 # 24 minute increment
223 unit_localization(:twenty_four_minute_increment, "en", :long,
224 nominative: %{
225 one: "{0} twenty-four minute increment",
226 other: "{0} twenty-four minute increments"
227 },
228 display_name: "24 minute increment"
229 )
230
231 unit_localization(:twenty_four_minute_increment, "en", :short,
232 nominative: %{
233 one: "{0} twenty-four mins",
234 other: "{0} twenty-four mins"
235 },
236 display_name: "24 mins"
237 )
238
239 unit_localization(:twenty_four_minute_increment, "en", :narrow,
240 nominative: %{
241 one: "{0} twenty-four min",
242 other: "{0} twenty-four min"
243 },
244 display_name: "24 min"
245 )
246
247 # 30 minute increment
248 unit_localization(:thirty_minute_increment, "en", :long,
249 nominative: %{
250 one: "{0} thirty minute increment",
251 other: "{0} thirty minute increments"
252 },
253 display_name: "30 minute increment"
254 )
255
256 unit_localization(:thirty_minute_increment, "en", :short,
257 nominative: %{
258 one: "{0} thirty mins",
259 other: "{0} thirty mins"
260 },
261 display_name: "30 mins"
262 )
263
264 unit_localization(:thirty_minute_increment, "en", :narrow,
265 nominative: %{
266 one: "{0} thirty min",
267 other: "{0} thirty min"
268 },
269 display_name: "30 min"
270 )
271
272 # 36 minute increment
273 unit_localization(:thirty_six_minute_increment, "en", :long,
274 nominative: %{
275 one: "{0} thirty-six minute increment",
276 other: "{0} thirty-six minute increments"
277 },
278 display_name: "36 minute increment"
279 )
280
281 unit_localization(:thirty_six_minute_increment, "en", :short,
282 nominative: %{
283 one: "{0} thirty-six mins",
284 other: "{0} thirty-six mins"
285 },
286 display_name: "36 mins"
287 )
288
289 unit_localization(:thirty_six_minute_increment, "en", :narrow,
290 nominative: %{
291 one: "{0} thirty-six min",
292 other: "{0} thirty-six min"
293 },
294 display_name: "36 min"
295 )
296
297 # 45 minute increment
298 unit_localization(:fourty_five_minute_increment, "en", :long,
299 nominative: %{
300 one: "{0} fourty-five minute increment",
301 other: "{0} fourty-five minute increments"
302 },
303 display_name: "45 minute increment"
304 )
305
306 unit_localization(:fourty_five_minute_increment, "en", :short,
307 nominative: %{
308 one: "{0} fourty-five mins",
309 other: "{0} fourty-five mins"
310 },
311 display_name: "45 mins"
312 )
313
314 unit_localization(:fourty_five_minute_increment, "en", :narrow,
315 nominative: %{
316 one: "{0} fourty-five min",
317 other: "{0} fourty-five min"
318 },
319 display_name: "45 min"
320 )
321
322 # 60 minute increment
323 unit_localization(:sixty_minute_increment, "en", :long,
324 nominative: %{
325 one: "{0} hour",
326 other: "{0} hours"
327 },
328 display_name: "60 minute increment"
329 )
330
331 unit_localization(:sixty_minute_increment, "en", :short,
332 nominative: %{
333 one: "{0} sixty mins",
334 other: "{0} sixty mins"
335 },
336 display_name: "60 mins"
337 )
338
339 unit_localization(:sixty_minute_increment, "en", :narrow,
340 nominative: %{
341 one: "{0} sixty min",
342 other: "{0} sixty min"
343 },
344 display_name: "60 min"
345 )
346
347 # hour increment
348 unit_localization(:hour_increment, "en", :long,
349 nominative: %{
350 one: "{0} hour",
351 other: "{0} hours"
352 },
353 display_name: "hour"
354 )
355
356 unit_localization(:hour_increment, "en", :short,
357 nominative: %{
358 one: "{0} hour",
359 other: "{0} hours"
360 },
361 display_name: "hour"
362 )
363
364 unit_localization(:hour_increment, "en", :narrow,
365 nominative: %{
366 one: "{0} hr",
367 other: "{0} hrs"
368 },
369 display_name: "hour"
370 )
371
372 # 90 minute increment
373 unit_localization(:ninety_minute_increment, "en", :long,
374 nominative: %{
375 one: "{0} ninety minute increment",
376 other: "{0} ninety minute increments"
377 },
378 display_name: "90 minute increment"
379 )
380
381 unit_localization(:ninety_minute_increment, "en", :short,
382 nominative: %{
383 one: "{0} ninety mins",
384 other: "{0} ninety mins"
385 },
386 display_name: "90 mins"
387 )
388
389 unit_localization(:ninety_minute_increment, "en", :narrow,
390 nominative: %{
391 one: "{0} ninety min",
392 other: "{0} ninety min"
393 },
394 display_name: "90 min"
395 )
396
397 # 120 minute increment
398 unit_localization(:one_hundred_twenty_minute_increment, "en", :long,
399 nominative: %{
400 one: "{0} one hundred twenty minute increment",
401 other: "{0} one hundred twenty minute increments"
402 },
403 display_name: "2 hour increment"
404 )
405
406 unit_localization(:one_hundred_twenty_minute_increment, "en", :short,
407 nominative: %{
408 one: "{0} one-twenty mins",
409 other: "{0} one-twenty mins"
410 },
411 display_name: "2 hours"
412 )
413
414 unit_localization(:one_hundred_twenty_minute_increment, "en", :narrow,
415 nominative: %{
416 one: "{0} one-twenty min",
417 other: "{0} one-twenty min"
418 },
419 display_name: "2 hour increment"
420 )
421
422 # "en-AU" locale
423 # minute increment
424 unit_localization(:minute_increment, "en-AU", :long,
425 nominative: %{
426 one: "{0} minute",
427 other: "{0} minutes"
428 },
429 display_name: "Minutes"
430 )
431
432 unit_localization(:minute_increment, "en-AU", :short,
433 nominative: %{
434 one: "{0} min",
435 other: "{0} mins"
436 },
437 display_name: "Minutes"
438 )
439
440 unit_localization(:minute_increment, "en-AU", :narrow,
441 nominative: %{
442 one: "{0} min",
443 other: "{0} mins"
444 },
445 display_name: "Minutes"
446 )
447
448 # 5 minute increment
449 unit_localization(:five_minute_increment, "en-AU", :long,
450 nominative: %{
451 one: "{0} five minute increment",
452 other: "{0} five minute increments"
453 },
454 display_name: "5 minutes"
455 )
456
457 unit_localization(:five_minute_increment, "en-AU", :short,
458 nominative: %{
459 one: "{0} five mins",
460 other: "{0} five mins"
461 },
462 display_name: "5 mins"
463 )
464
465 unit_localization(:five_minute_increment, "en-AU", :narrow,
466 nominative: %{
467 one: "{0} five min",
468 other: "{0} five min"
469 },
470 display_name: "5 min"
471 )
472
473 # 6 minute increment
474 unit_localization(:six_minute_increment, "en-AU", :long,
475 nominative: %{
476 one: "{0} six minute increment",
477 other: "{0} six minute increments"
478 },
479 display_name: "6 minutes"
480 )
481
482 unit_localization(:six_minute_increment, "en-AU", :short,
483 nominative: %{
484 one: "{0} six mins",
485 other: "{0} six mins"
486 },
487 display_name: "6 mins"
488 )
489
490 unit_localization(:six_minute_increment, "en-AU", :narrow,
491 nominative: %{
492 one: "{0} six min",
493 other: "{0} six min"
494 },
495 display_name: "6 min"
496 )
497
498 # 10 minute increment
499 unit_localization(:ten_minute_increment, "en-AU", :long,
500 nominative: %{
501 one: "{0} ten minute increment",
502 other: "{0} ten minute increments"
503 },
504 display_name: "10 minute increment"
505 )
506
507 unit_localization(:ten_minute_increment, "en-AU", :short,
508 nominative: %{
509 one: "{0} ten mins",
510 other: "{0} ten mins"
511 },
512 display_name: "10 mins"
513 )
514
515 unit_localization(:ten_minute_increment, "en-AU", :narrow,
516 nominative: %{
517 one: "{0} ten min",
518 other: "{0} ten min"
519 },
520 display_name: "10 min"
521 )
522
523 # 12 minute increment
524 unit_localization(:twelve_minute_increment, "en-AU", :long,
525 nominative: %{
526 one: "{0} twelve minute increment",
527 other: "{0} twelve minute increments"
528 },
529 display_name: "12 minute increment"
530 )
531
532 unit_localization(:twelve_minute_increment, "en-AU", :short,
533 nominative: %{
534 one: "{0} twelve mins",
535 other: "{0} twelve mins"
536 },
537 display_name: "12 mins"
538 )
539
540 unit_localization(:twelve_minute_increment, "en-AU", :narrow,
541 nominative: %{
542 one: "{0} twelve min",
543 other: "{0} twelve min"
544 },
545 display_name: "12 min"
546 )
547
548 # 15 minute increment
549 unit_localization(:fifteen_minute_increment, "en-AU", :long,
550 nominative: %{
551 one: "{0} fifteen minute increment",
552 other: "{0} fifteen minute increments"
553 },
554 display_name: "15 minute increment"
555 )
556
557 unit_localization(:fifteen_minute_increment, "en-AU", :short,
558 nominative: %{
559 one: "{0} fifteen mins",
560 other: "{0} fifteen mins"
561 },
562 display_name: "15 mins"
563 )
564
565 unit_localization(:fifteen_minute_increment, "en-AU", :narrow,
566 nominative: %{
567 one: "{0} fifteen min",
568 other: "{0} fifteen min"
569 },
570 display_name: "15 min"
571 )
572
573 # 18 minute increment
574 unit_localization(:eighteen_minute_increment, "en-AU", :long,
575 nominative: %{
576 one: "{0} eighteen minute increment",
577 other: "{0} eighteen minute increments"
578 },
579 display_name: "18 minute increment"
580 )
581
582 unit_localization(:eighteen_minute_increment, "en-AU", :short,
583 nominative: %{
584 one: "{0} eighteen mins",
585 other: "{0} eighteen mins"
586 },
587 display_name: "18 mins"
588 )
589
590 unit_localization(:eighteen_minute_increment, "en-AU", :narrow,
591 nominative: %{
592 one: "{0} eighteen min",
593 other: "{0} eighteen min"
594 },
595 display_name: "18 min"
596 )
597
598 # 20 minute increment
599 unit_localization(:twenty_minute_increment, "en-AU", :long,
600 nominative: %{
601 one: "{0} twenty minute increment",
602 other: "{0} twenty minute increments"
603 },
604 display_name: "20 minute increment"
605 )
606
607 unit_localization(:twenty_minute_increment, "en-AU", :short,
608 nominative: %{
609 one: "{0} twenty mins",
610 other: "{0} twenty mins"
611 },
612 display_name: "20 mins"
613 )
614
615 unit_localization(:twenty_minute_increment, "en-AU", :narrow,
616 nominative: %{
617 one: "{0} twenty min",
618 other: "{0} twenty min"
619 },
620 display_name: "20 min"
621 )
622
623 # 24 minute increment
624 unit_localization(:twenty_four_minute_increment, "en-AU", :long,
625 nominative: %{
626 one: "{0} twenty-four minute increment",
627 other: "{0} twenty-four minute increments"
628 },
629 display_name: "24 minute increment"
630 )
631
632 unit_localization(:twenty_four_minute_increment, "en-AU", :short,
633 nominative: %{
634 one: "{0} twenty-four mins",
635 other: "{0} twenty-four mins"
636 },
637 display_name: "24 mins"
638 )
639
640 unit_localization(:twenty_four_minute_increment, "en-AU", :narrow,
641 nominative: %{
642 one: "{0} twenty-four min",
643 other: "{0} twenty-four min"
644 },
645 display_name: "24 min"
646 )
647
648 # 30 minute increment
649 unit_localization(:thirty_minute_increment, "en-AU", :long,
650 nominative: %{
651 one: "{0} thirty minute increment",
652 other: "{0} thirty minute increments"
653 },
654 display_name: "30 minute increment"
655 )
656
657 unit_localization(:thirty_minute_increment, "en-AU", :short,
658 nominative: %{
659 one: "{0} thirty mins",
660 other: "{0} thirty mins"
661 },
662 display_name: "30 mins"
663 )
664
665 unit_localization(:thirty_minute_increment, "en-AU", :narrow,
666 nominative: %{
667 one: "{0} thirty min",
668 other: "{0} thirty min"
669 },
670 display_name: "30 min"
671 )
672
673 # 36 minute increment
674 unit_localization(:thirty_six_minute_increment, "en-AU", :long,
675 nominative: %{
676 one: "{0} thirty-six minute increment",
677 other: "{0} thirty-six minute increments"
678 },
679 display_name: "36 minute increment"
680 )
681
682 unit_localization(:thirty_six_minute_increment, "en-AU", :short,
683 nominative: %{
684 one: "{0} thirty-six mins",
685 other: "{0} thirty-six mins"
686 },
687 display_name: "36 mins"
688 )
689
690 unit_localization(:thirty_six_minute_increment, "en-AU", :narrow,
691 nominative: %{
692 one: "{0} thirty-six min",
693 other: "{0} thirty-six min"
694 },
695 display_name: "36 min"
696 )
697
698 # 45 minute increment
699 unit_localization(:fourty_five_minute_increment, "en-AU", :long,
700 nominative: %{
701 one: "{0} fourty-five minute increment",
702 other: "{0} fourty-five minute increments"
703 },
704 display_name: "45 minute increment"
705 )
706
707 unit_localization(:fourty_five_minute_increment, "en-AU", :short,
708 nominative: %{
709 one: "{0} fourty-five mins",
710 other: "{0} fourty-five mins"
711 },
712 display_name: "45 mins"
713 )
714
715 unit_localization(:fourty_five_minute_increment, "en-AU", :narrow,
716 nominative: %{
717 one: "{0} fourty-five min",
718 other: "{0} fourty-five min"
719 },
720 display_name: "45 min"
721 )
722
723 # 60 minute increment
724 unit_localization(:sixty_minute_increment, "en-AU", :long,
725 nominative: %{
726 one: "{0} hour",
727 other: "{0} hours"
728 },
729 display_name: "60 minute increment"
730 )
731
732 unit_localization(:sixty_minute_increment, "en-AU", :short,
733 nominative: %{
734 one: "{0} sixty mins",
735 other: "{0} sixty mins"
736 },
737 display_name: "60 mins"
738 )
739
740 unit_localization(:sixty_minute_increment, "en-AU", :narrow,
741 nominative: %{
742 one: "{0} sixty min",
743 other: "{0} sixty min"
744 },
745 display_name: "60 min"
746 )
747
748 # hour increment
749 unit_localization(:hour_increment, "en-AU", :long,
750 nominative: %{
751 one: "{0} hour",
752 other: "{0} hours"
753 },
754 display_name: "hour"
755 )
756
757 unit_localization(:hour_increment, "en-AU", :short,
758 nominative: %{
759 one: "{0} hour",
760 other: "{0} hours"
761 },
762 display_name: "hour"
763 )
764
765 unit_localization(:hour_increment, "en-AU", :narrow,
766 nominative: %{
767 one: "{0} hr",
768 other: "{0} hrs"
769 },
770 display_name: "hour"
771 )
772
773 # 90 minute increment
774 unit_localization(:ninety_minute_increment, "en-AU", :long,
775 nominative: %{
776 one: "{0} ninety minute increment",
777 other: "{0} ninety minute increments"
778 },
779 display_name: "90 minute increment"
780 )
781
782 unit_localization(:ninety_minute_increment, "en-AU", :short,
783 nominative: %{
784 one: "{0} ninety mins",
785 other: "{0} ninety mins"
786 },
787 display_name: "90 mins"
788 )
789
790 unit_localization(:ninety_minute_increment, "en-AU", :narrow,
791 nominative: %{
792 one: "{0} ninety min",
793 other: "{0} ninety min"
794 },
795 display_name: "90 min"
796 )
797
798 # 120 minute increment
799 unit_localization(:one_hundred_twenty_minute_increment, "en-AU", :long,
800 nominative: %{
801 one: "{0} one hundred twenty minute increment",
802 other: "{0} one hundred twenty minute increments"
803 },
804 display_name: "2 hour increment"
805 )
806
807 unit_localization(:one_hundred_twenty_minute_increment, "en-AU", :short,
808 nominative: %{
809 one: "{0} one-twenty mins",
810 other: "{0} one-twenty mins"
811 },
812 display_name: "2 hours"
813 )
814
815 unit_localization(:one_hundred_twenty_minute_increment, "en-AU", :narrow,
816 nominative: %{
817 one: "{0} one-twenty min",
818 other: "{0} one-twenty min"
819 },
820 display_name: "2 hour increment"
821 )
822
823 # "en-CA" locale
824 # minute increment
825 unit_localization(:minute_increment, "en-CA", :long,
826 nominative: %{
827 one: "{0} minute",
828 other: "{0} minutes"
829 },
830 display_name: "Minutes"
831 )
832
833 unit_localization(:minute_increment, "en-CA", :short,
834 nominative: %{
835 one: "{0} min",
836 other: "{0} mins"
837 },
838 display_name: "Minutes"
839 )
840
841 unit_localization(:minute_increment, "en-CA", :narrow,
842 nominative: %{
843 one: "{0} min",
844 other: "{0} mins"
845 },
846 display_name: "Minutes"
847 )
848
849 # 5 minute increment
850 unit_localization(:five_minute_increment, "en-CA", :long,
851 nominative: %{
852 one: "{0} five minute increment",
853 other: "{0} five minute increments"
854 },
855 display_name: "5 minutes"
856 )
857
858 unit_localization(:five_minute_increment, "en-CA", :short,
859 nominative: %{
860 one: "{0} five mins",
861 other: "{0} five mins"
862 },
863 display_name: "5 mins"
864 )
865
866 unit_localization(:five_minute_increment, "en-CA", :narrow,
867 nominative: %{
868 one: "{0} five min",
869 other: "{0} five min"
870 },
871 display_name: "5 min"
872 )
873
874 # 6 minute increment
875 unit_localization(:six_minute_increment, "en-CA", :long,
876 nominative: %{
877 one: "{0} six minute increment",
878 other: "{0} six minute increments"
879 },
880 display_name: "6 minutes"
881 )
882
883 unit_localization(:six_minute_increment, "en-CA", :short,
884 nominative: %{
885 one: "{0} six mins",
886 other: "{0} six mins"
887 },
888 display_name: "6 mins"
889 )
890
891 unit_localization(:six_minute_increment, "en-CA", :narrow,
892 nominative: %{
893 one: "{0} six min",
894 other: "{0} six min"
895 },
896 display_name: "6 min"
897 )
898
899 # 10 minute increment
900 unit_localization(:ten_minute_increment, "en-CA", :long,
901 nominative: %{
902 one: "{0} ten minute increment",
903 other: "{0} ten minute increments"
904 },
905 display_name: "10 minute increment"
906 )
907
908 unit_localization(:ten_minute_increment, "en-CA", :short,
909 nominative: %{
910 one: "{0} ten mins",
911 other: "{0} ten mins"
912 },
913 display_name: "10 mins"
914 )
915
916 unit_localization(:ten_minute_increment, "en-CA", :narrow,
917 nominative: %{
918 one: "{0} ten min",
919 other: "{0} ten min"
920 },
921 display_name: "10 min"
922 )
923
924 # 12 minute increment
925 unit_localization(:twelve_minute_increment, "en-CA", :long,
926 nominative: %{
927 one: "{0} twelve minute increment",
928 other: "{0} twelve minute increments"
929 },
930 display_name: "12 minute increment"
931 )
932
933 unit_localization(:twelve_minute_increment, "en-CA", :short,
934 nominative: %{
935 one: "{0} twelve mins",
936 other: "{0} twelve mins"
937 },
938 display_name: "12 mins"
939 )
940
941 unit_localization(:twelve_minute_increment, "en-CA", :narrow,
942 nominative: %{
943 one: "{0} twelve min",
944 other: "{0} twelve min"
945 },
946 display_name: "12 min"
947 )
948
949 # 15 minute increment
950 unit_localization(:fifteen_minute_increment, "en-CA", :long,
951 nominative: %{
952 one: "{0} fifteen minute increment",
953 other: "{0} fifteen minute increments"
954 },
955 display_name: "15 minute increment"
956 )
957
958 unit_localization(:fifteen_minute_increment, "en-CA", :short,
959 nominative: %{
960 one: "{0} fifteen mins",
961 other: "{0} fifteen mins"
962 },
963 display_name: "15 mins"
964 )
965
966 unit_localization(:fifteen_minute_increment, "en-CA", :narrow,
967 nominative: %{
968 one: "{0} fifteen min",
969 other: "{0} fifteen min"
970 },
971 display_name: "15 min"
972 )
973
974 # 18 minute increment
975 unit_localization(:eighteen_minute_increment, "en-CA", :long,
976 nominative: %{
977 one: "{0} eighteen minute increment",
978 other: "{0} eighteen minute increments"
979 },
980 display_name: "18 minute increment"
981 )
982
983 unit_localization(:eighteen_minute_increment, "en-CA", :short,
984 nominative: %{
985 one: "{0} eighteen mins",
986 other: "{0} eighteen mins"
987 },
988 display_name: "18 mins"
989 )
990
991 unit_localization(:eighteen_minute_increment, "en-CA", :narrow,
992 nominative: %{
993 one: "{0} eighteen min",
994 other: "{0} eighteen min"
995 },
996 display_name: "18 min"
997 )
998
999 # 20 minute increment
1000 unit_localization(:twenty_minute_increment, "en-CA", :long,
1001 nominative: %{
1002 one: "{0} twenty minute increment",
1003 other: "{0} twenty minute increments"
1004 },
1005 display_name: "20 minute increment"
1006 )
1007
1008 unit_localization(:twenty_minute_increment, "en-CA", :short,
1009 nominative: %{
1010 one: "{0} twenty mins",
1011 other: "{0} twenty mins"
1012 },
1013 display_name: "20 mins"
1014 )
1015
1016 unit_localization(:twenty_minute_increment, "en-CA", :narrow,
1017 nominative: %{
1018 one: "{0} twenty min",
1019 other: "{0} twenty min"
1020 },
1021 display_name: "20 min"
1022 )
1023
1024 # 24 minute increment
1025 unit_localization(:twenty_four_minute_increment, "en-CA", :long,
1026 nominative: %{
1027 one: "{0} twenty-four minute increment",
1028 other: "{0} twenty-four minute increments"
1029 },
1030 display_name: "24 minute increment"
1031 )
1032
1033 unit_localization(:twenty_four_minute_increment, "en-CA", :short,
1034 nominative: %{
1035 one: "{0} twenty-four mins",
1036 other: "{0} twenty-four mins"
1037 },
1038 display_name: "24 mins"
1039 )
1040
1041 unit_localization(:twenty_four_minute_increment, "en-CA", :narrow,
1042 nominative: %{
1043 one: "{0} twenty-four min",
1044 other: "{0} twenty-four min"
1045 },
1046 display_name: "24 min"
1047 )
1048
1049 # 30 minute increment
1050 unit_localization(:thirty_minute_increment, "en-CA", :long,
1051 nominative: %{
1052 one: "{0} thirty minute increment",
1053 other: "{0} thirty minute increments"
1054 },
1055 display_name: "30 minute increment"
1056 )
1057
1058 unit_localization(:thirty_minute_increment, "en-CA", :short,
1059 nominative: %{
1060 one: "{0} thirty mins",
1061 other: "{0} thirty mins"
1062 },
1063 display_name: "30 mins"
1064 )
1065
1066 unit_localization(:thirty_minute_increment, "en-CA", :narrow,
1067 nominative: %{
1068 one: "{0} thirty min",
1069 other: "{0} thirty min"
1070 },
1071 display_name: "30 min"
1072 )
1073
1074 # 36 minute increment
1075 unit_localization(:thirty_six_minute_increment, "en-CA", :long,
1076 nominative: %{
1077 one: "{0} thirty-six minute increment",
1078 other: "{0} thirty-six minute increments"
1079 },
1080 display_name: "36 minute increment"
1081 )
1082
1083 unit_localization(:thirty_six_minute_increment, "en-CA", :short,
1084 nominative: %{
1085 one: "{0} thirty-six mins",
1086 other: "{0} thirty-six mins"
1087 },
1088 display_name: "36 mins"
1089 )
1090
1091 unit_localization(:thirty_six_minute_increment, "en-CA", :narrow,
1092 nominative: %{
1093 one: "{0} thirty-six min",
1094 other: "{0} thirty-six min"
1095 },
1096 display_name: "36 min"
1097 )
1098
1099 # 45 minute increment
1100 unit_localization(:fourty_five_minute_increment, "en-CA", :long,
1101 nominative: %{
1102 one: "{0} fourty-five minute increment",
1103 other: "{0} fourty-five minute increments"
1104 },
1105 display_name: "45 minute increment"
1106 )
1107
1108 unit_localization(:fourty_five_minute_increment, "en-CA", :short,
1109 nominative: %{
1110 one: "{0} fourty-five mins",
1111 other: "{0} fourty-five mins"
1112 },
1113 display_name: "45 mins"
1114 )
1115
1116 unit_localization(:fourty_five_minute_increment, "en-CA", :narrow,
1117 nominative: %{
1118 one: "{0} fourty-five min",
1119 other: "{0} fourty-five min"
1120 },
1121 display_name: "45 min"
1122 )
1123
1124 # 60 minute increment
1125 unit_localization(:sixty_minute_increment, "en-CA", :long,
1126 nominative: %{
1127 one: "{0} hour",
1128 other: "{0} hours"
1129 },
1130 display_name: "60 minute increment"
1131 )
1132
1133 unit_localization(:sixty_minute_increment, "en-CA", :short,
1134 nominative: %{
1135 one: "{0} sixty mins",
1136 other: "{0} sixty mins"
1137 },
1138 display_name: "60 mins"
1139 )
1140
1141 unit_localization(:sixty_minute_increment, "en-CA", :narrow,
1142 nominative: %{
1143 one: "{0} sixty min",
1144 other: "{0} sixty min"
1145 },
1146 display_name: "60 min"
1147 )
1148
1149 # hour increment
1150 unit_localization(:hour_increment, "en-CA", :long,
1151 nominative: %{
1152 one: "{0} hour",
1153 other: "{0} hours"
1154 },
1155 display_name: "hour"
1156 )
1157
1158 unit_localization(:hour_increment, "en-CA", :short,
1159 nominative: %{
1160 one: "{0} hour",
1161 other: "{0} hours"
1162 },
1163 display_name: "hour"
1164 )
1165
1166 unit_localization(:hour_increment, "en-CA", :narrow,
1167 nominative: %{
1168 one: "{0} hr",
1169 other: "{0} hrs"
1170 },
1171 display_name: "hour"
1172 )
1173
1174 # 90 minute increment
1175 unit_localization(:ninety_minute_increment, "en-CA", :long,
1176 nominative: %{
1177 one: "{0} ninety minute increment",
1178 other: "{0} ninety minute increments"
1179 },
1180 display_name: "90 minute increment"
1181 )
1182
1183 unit_localization(:ninety_minute_increment, "en-CA", :short,
1184 nominative: %{
1185 one: "{0} ninety mins",
1186 other: "{0} ninety mins"
1187 },
1188 display_name: "90 mins"
1189 )
1190
1191 unit_localization(:ninety_minute_increment, "en-CA", :narrow,
1192 nominative: %{
1193 one: "{0} ninety min",
1194 other: "{0} ninety min"
1195 },
1196 display_name: "90 min"
1197 )
1198
1199 # 120 minute increment
1200 unit_localization(:one_hundred_twenty_minute_increment, "en-CA", :long,
1201 nominative: %{
1202 one: "{0} one hundred twenty minute increment",
1203 other: "{0} one hundred twenty minute increments"
1204 },
1205 display_name: "2 hour increment"
1206 )
1207
1208 unit_localization(:one_hundred_twenty_minute_increment, "en-CA", :short,
1209 nominative: %{
1210 one: "{0} one-twenty mins",
1211 other: "{0} one-twenty mins"
1212 },
1213 display_name: "2 hours"
1214 )
1215
1216 unit_localization(:one_hundred_twenty_minute_increment, "en-CA", :narrow,
1217 nominative: %{
1218 one: "{0} one-twenty min",
1219 other: "{0} one-twenty min"
1220 },
1221 display_name: "2 hour increment"
1222 )
1223
1224 # "en-GB" locale
1225 # minute increment
1226 unit_localization(:minute_increment, "en-GB", :long,
1227 nominative: %{
1228 one: "{0} minute",
1229 other: "{0} minutes"
1230 },
1231 display_name: "Minutes"
1232 )
1233
1234 unit_localization(:minute_increment, "en-GB", :short,
1235 nominative: %{
1236 one: "{0} min",
1237 other: "{0} mins"
1238 },
1239 display_name: "Minutes"
1240 )
1241
1242 unit_localization(:minute_increment, "en-GB", :narrow,
1243 nominative: %{
1244 one: "{0} min",
1245 other: "{0} mins"
1246 },
1247 display_name: "Minutes"
1248 )
1249
1250 # 5 minute increment
1251 unit_localization(:five_minute_increment, "en-GB", :long,
1252 nominative: %{
1253 one: "{0} five minute increment",
1254 other: "{0} five minute increments"
1255 },
1256 display_name: "5 minutes"
1257 )
1258
1259 unit_localization(:five_minute_increment, "en-GB", :short,
1260 nominative: %{
1261 one: "{0} five mins",
1262 other: "{0} five mins"
1263 },
1264 display_name: "5 mins"
1265 )
1266
1267 unit_localization(:five_minute_increment, "en-GB", :narrow,
1268 nominative: %{
1269 one: "{0} five min",
1270 other: "{0} five min"
1271 },
1272 display_name: "5 min"
1273 )
1274
1275 # 6 minute increment
1276 unit_localization(:six_minute_increment, "en-GB", :long,
1277 nominative: %{
1278 one: "{0} six minute increment",
1279 other: "{0} six minute increments"
1280 },
1281 display_name: "6 minutes"
1282 )
1283
1284 unit_localization(:six_minute_increment, "en-GB", :short,
1285 nominative: %{
1286 one: "{0} six mins",
1287 other: "{0} six mins"
1288 },
1289 display_name: "6 mins"
1290 )
1291
1292 unit_localization(:six_minute_increment, "en-GB", :narrow,
1293 nominative: %{
1294 one: "{0} six min",
1295 other: "{0} six min"
1296 },
1297 display_name: "6 min"
1298 )
1299
1300 # 10 minute increment
1301 unit_localization(:ten_minute_increment, "en-GB", :long,
1302 nominative: %{
1303 one: "{0} ten minute increment",
1304 other: "{0} ten minute increments"
1305 },
1306 display_name: "10 minute increment"
1307 )
1308
1309 unit_localization(:ten_minute_increment, "en-GB", :short,
1310 nominative: %{
1311 one: "{0} ten mins",
1312 other: "{0} ten mins"
1313 },
1314 display_name: "10 mins"
1315 )
1316
1317 unit_localization(:ten_minute_increment, "en-GB", :narrow,
1318 nominative: %{
1319 one: "{0} ten min",
1320 other: "{0} ten min"
1321 },
1322 display_name: "10 min"
1323 )
1324
1325 # 12 minute increment
1326 unit_localization(:twelve_minute_increment, "en-GB", :long,
1327 nominative: %{
1328 one: "{0} twelve minute increment",
1329 other: "{0} twelve minute increments"
1330 },
1331 display_name: "12 minute increment"
1332 )
1333
1334 unit_localization(:twelve_minute_increment, "en-GB", :short,
1335 nominative: %{
1336 one: "{0} twelve mins",
1337 other: "{0} twelve mins"
1338 },
1339 display_name: "12 mins"
1340 )
1341
1342 unit_localization(:twelve_minute_increment, "en-GB", :narrow,
1343 nominative: %{
1344 one: "{0} twelve min",
1345 other: "{0} twelve min"
1346 },
1347 display_name: "12 min"
1348 )
1349
1350 # 15 minute increment
1351 unit_localization(:fifteen_minute_increment, "en-GB", :long,
1352 nominative: %{
1353 one: "{0} fifteen minute increment",
1354 other: "{0} fifteen minute increments"
1355 },
1356 display_name: "15 minute increment"
1357 )
1358
1359 unit_localization(:fifteen_minute_increment, "en-GB", :short,
1360 nominative: %{
1361 one: "{0} fifteen mins",
1362 other: "{0} fifteen mins"
1363 },
1364 display_name: "15 mins"
1365 )
1366
1367 unit_localization(:fifteen_minute_increment, "en-GB", :narrow,
1368 nominative: %{
1369 one: "{0} fifteen min",
1370 other: "{0} fifteen min"
1371 },
1372 display_name: "15 min"
1373 )
1374
1375 # 18 minute increment
1376 unit_localization(:eighteen_minute_increment, "en-GB", :long,
1377 nominative: %{
1378 one: "{0} eighteen minute increment",
1379 other: "{0} eighteen minute increments"
1380 },
1381 display_name: "18 minute increment"
1382 )
1383
1384 unit_localization(:eighteen_minute_increment, "en-GB", :short,
1385 nominative: %{
1386 one: "{0} eighteen mins",
1387 other: "{0} eighteen mins"
1388 },
1389 display_name: "18 mins"
1390 )
1391
1392 unit_localization(:eighteen_minute_increment, "en-GB", :narrow,
1393 nominative: %{
1394 one: "{0} eighteen min",
1395 other: "{0} eighteen min"
1396 },
1397 display_name: "18 min"
1398 )
1399
1400 # 20 minute increment
1401 unit_localization(:twenty_minute_increment, "en-GB", :long,
1402 nominative: %{
1403 one: "{0} twenty minute increment",
1404 other: "{0} twenty minute increments"
1405 },
1406 display_name: "20 minute increment"
1407 )
1408
1409 unit_localization(:twenty_minute_increment, "en-GB", :short,
1410 nominative: %{
1411 one: "{0} twenty mins",
1412 other: "{0} twenty mins"
1413 },
1414 display_name: "20 mins"
1415 )
1416
1417 unit_localization(:twenty_minute_increment, "en-GB", :narrow,
1418 nominative: %{
1419 one: "{0} twenty min",
1420 other: "{0} twenty min"
1421 },
1422 display_name: "20 min"
1423 )
1424
1425 # 24 minute increment
1426 unit_localization(:twenty_four_minute_increment, "en-GB", :long,
1427 nominative: %{
1428 one: "{0} twenty-four minute increment",
1429 other: "{0} twenty-four minute increments"
1430 },
1431 display_name: "24 minute increment"
1432 )
1433
1434 unit_localization(:twenty_four_minute_increment, "en-GB", :short,
1435 nominative: %{
1436 one: "{0} twenty-four mins",
1437 other: "{0} twenty-four mins"
1438 },
1439 display_name: "24 mins"
1440 )
1441
1442 unit_localization(:twenty_four_minute_increment, "en-GB", :narrow,
1443 nominative: %{
1444 one: "{0} twenty-four min",
1445 other: "{0} twenty-four min"
1446 },
1447 display_name: "24 min"
1448 )
1449
1450 # 30 minute increment
1451 unit_localization(:thirty_minute_increment, "en-GB", :long,
1452 nominative: %{
1453 one: "{0} thirty minute increment",
1454 other: "{0} thirty minute increments"
1455 },
1456 display_name: "30 minute increment"
1457 )
1458
1459 unit_localization(:thirty_minute_increment, "en-GB", :short,
1460 nominative: %{
1461 one: "{0} thirty mins",
1462 other: "{0} thirty mins"
1463 },
1464 display_name: "30 mins"
1465 )
1466
1467 unit_localization(:thirty_minute_increment, "en-GB", :narrow,
1468 nominative: %{
1469 one: "{0} thirty min",
1470 other: "{0} thirty min"
1471 },
1472 display_name: "30 min"
1473 )
1474
1475 # 36 minute increment
1476 unit_localization(:thirty_six_minute_increment, "en-GB", :long,
1477 nominative: %{
1478 one: "{0} thirty-six minute increment",
1479 other: "{0} thirty-six minute increments"
1480 },
1481 display_name: "36 minute increment"
1482 )
1483
1484 unit_localization(:thirty_six_minute_increment, "en-GB", :short,
1485 nominative: %{
1486 one: "{0} thirty-six mins",
1487 other: "{0} thirty-six mins"
1488 },
1489 display_name: "36 mins"
1490 )
1491
1492 unit_localization(:thirty_six_minute_increment, "en-GB", :narrow,
1493 nominative: %{
1494 one: "{0} thirty-six min",
1495 other: "{0} thirty-six min"
1496 },
1497 display_name: "36 min"
1498 )
1499
1500 # 45 minute increment
1501 unit_localization(:fourty_five_minute_increment, "en-GB", :long,
1502 nominative: %{
1503 one: "{0} fourty-five minute increment",
1504 other: "{0} fourty-five minute increments"
1505 },
1506 display_name: "45 minute increment"
1507 )
1508
1509 unit_localization(:fourty_five_minute_increment, "en-GB", :short,
1510 nominative: %{
1511 one: "{0} fourty-five mins",
1512 other: "{0} fourty-five mins"
1513 },
1514 display_name: "45 mins"
1515 )
1516
1517 unit_localization(:fourty_five_minute_increment, "en-GB", :narrow,
1518 nominative: %{
1519 one: "{0} fourty-five min",
1520 other: "{0} fourty-five min"
1521 },
1522 display_name: "45 min"
1523 )
1524
1525 # 60 minute increment
1526 unit_localization(:sixty_minute_increment, "en-GB", :long,
1527 nominative: %{
1528 one: "{0} hour",
1529 other: "{0} hours"
1530 },
1531 display_name: "60 minute increment"
1532 )
1533
1534 unit_localization(:sixty_minute_increment, "en-GB", :short,
1535 nominative: %{
1536 one: "{0} sixty mins",
1537 other: "{0} sixty mins"
1538 },
1539 display_name: "60 mins"
1540 )
1541
1542 unit_localization(:sixty_minute_increment, "en-GB", :narrow,
1543 nominative: %{
1544 one: "{0} sixty min",
1545 other: "{0} sixty min"
1546 },
1547 display_name: "60 min"
1548 )
1549
1550 # hour increment
1551 unit_localization(:hour_increment, "en-GB", :long,
1552 nominative: %{
1553 one: "{0} hour",
1554 other: "{0} hours"
1555 },
1556 display_name: "hour"
1557 )
1558
1559 unit_localization(:hour_increment, "en-GB", :short,
1560 nominative: %{
1561 one: "{0} hour",
1562 other: "{0} hours"
1563 },
1564 display_name: "hour"
1565 )
1566
1567 unit_localization(:hour_increment, "en-GB", :narrow,
1568 nominative: %{
1569 one: "{0} hr",
1570 other: "{0} hrs"
1571 },
1572 display_name: "hour"
1573 )
1574
1575 # 90 minute increment
1576 unit_localization(:ninety_minute_increment, "en-GB", :long,
1577 nominative: %{
1578 one: "{0} ninety minute increment",
1579 other: "{0} ninety minute increments"
1580 },
1581 display_name: "90 minute increment"
1582 )
1583
1584 unit_localization(:ninety_minute_increment, "en-GB", :short,
1585 nominative: %{
1586 one: "{0} ninety mins",
1587 other: "{0} ninety mins"
1588 },
1589 display_name: "90 mins"
1590 )
1591
1592 unit_localization(:ninety_minute_increment, "en-GB", :narrow,
1593 nominative: %{
1594 one: "{0} ninety min",
1595 other: "{0} ninety min"
1596 },
1597 display_name: "90 min"
1598 )
1599
1600 # 120 minute increment
1601 unit_localization(:one_hundred_twenty_minute_increment, "en-GB", :long,
1602 nominative: %{
1603 one: "{0} one hundred twenty minute increment",
1604 other: "{0} one hundred twenty minute increments"
1605 },
1606 display_name: "2 hour increment"
1607 )
1608
1609 unit_localization(:one_hundred_twenty_minute_increment, "en-GB", :short,
1610 nominative: %{
1611 one: "{0} one-twenty mins",
1612 other: "{0} one-twenty mins"
1613 },
1614 display_name: "2 hours"
1615 )
1616
1617 unit_localization(:one_hundred_twenty_minute_increment, "en-GB", :narrow,
1618 nominative: %{
1619 one: "{0} one-twenty min",
1620 other: "{0} one-twenty min"
1621 },
1622 display_name: "2 hour increment"
1623 )
1624 end

lib/klepsidra/journals.ex

46.1
13
31
7
Line Hits Source
0 defmodule Klepsidra.Journals do
1 @moduledoc """
2 The Journals context.
3 """
4
5 # import Ecto.Query, warn: false
6 import Ecto.Query
7 alias Klepsidra.Repo
8
9 alias Klepsidra.Journals.JournalEntry
10
11 @doc """
12 Returns the list of journal_entries.
13
14 ## Examples
15
16 iex> list_journal_entries()
17 [%JournalEntry{}, ...]
18
19 """
20 @spec list_journal_entries() :: [JournalEntry.t(), ...]
21 def list_journal_entries do
22 0 JournalEntry |> order_by(asc: :journal_for, asc: :inserted_at) |> Repo.all()
23 end
24
25 @doc """
26 Given a result of a `journal_entries` query, additionally preload the
27 `entry_type` association.
28
29 ## Examples
30
31 iex> list_journal_entries() |> preload_journal_entry_type()
32 [%JournalEntry{%Klepsidra.Journals.JournalEntryTypes{}}, ...]
33
34 """
35 @spec preload_journal_entry_type(journal_entries :: [JournalEntry.t(), ...]) ::
36 [JournalEntry.t(), ...]
37 def preload_journal_entry_type(journal_entries) when is_list(journal_entries) do
38 0 Repo.preload(journal_entries, :entry_type)
39 end
40
41 @doc """
42 Gets a single journal_entry.
43
44 Raises `Ecto.NoResultsError` if the Journal entry does not exist.
45
46 ## Examples
47
48 iex> get_journal_entry!(123)
49 %JournalEntry{}
50
51 iex> get_journal_entry!(456)
52 ** (Ecto.NoResultsError)
53
54 """
55 @spec get_journal_entry!(id :: Ecto.UUID.t()) ::
56 JournalEntry.t()
57 0 def get_journal_entry!(id), do: Repo.get!(JournalEntry, id)
58
59 @doc """
60 Creates a journal_entry.
61
62 ## Examples
63
64 iex> create_journal_entry(%{field: value})
65 {:ok, %JournalEntry{}}
66
67 iex> create_journal_entry(%{field: bad_value})
68 {:error, Ecto.Changeset.t()}
69
70 """
71 @spec create_journal_entry(attrs :: map()) ::
72 {:ok, JournalEntry.t()} | {:error, Ecto.Changeset.t()}
73 def create_journal_entry(attrs \\ %{}) do
74 %JournalEntry{}
75 |> JournalEntry.changeset(attrs)
76 0 |> Repo.insert()
77 end
78
79 @doc """
80 Updates a journal_entry.
81
82 ## Examples
83
84 iex> update_journal_entry(journal_entry, %{field: new_value})
85 {:ok, %JournalEntry{}}
86
87 iex> update_journal_entry(journal_entry, %{field: bad_value})
88 {:error, Ecto.Changeset.t()}
89
90 """
91 @spec update_journal_entry(
92 journal_entry :: JournalEntry.t(),
93 attrs :: map()
94 ) ::
95 {:ok, JournalEntry.t()} | {:error, Ecto.Changeset.t()}
96 def update_journal_entry(%JournalEntry{} = journal_entry, attrs) do
97 journal_entry
98 |> JournalEntry.changeset(attrs)
99 0 |> Repo.update()
100 end
101
102 @doc """
103 Deletes a journal_entry.
104
105 ## Examples
106
107 iex> delete_journal_entry(journal_entry)
108 {:ok, %JournalEntry{}}
109
110 iex> delete_journal_entry(journal_entry)
111 {:error, Ecto.Changeset.t()}
112
113 """
114 @spec delete_journal_entry(journal_entry :: JournalEntry.t()) ::
115 {:ok, JournalEntry.t()} | {:error, Ecto.Changeset.t()}
116 def delete_journal_entry(%JournalEntry{} = journal_entry) do
117 0 Repo.delete(journal_entry)
118 end
119
120 @doc """
121 Returns an `Ecto.Changeset.t()` for tracking journal_entry changes.
122
123 ## Examples
124
125 iex> change_journal_entry(journal_entry)
126 %Ecto.Changeset{data: %JournalEntry{}}
127
128 """
129 @spec change_journal_entry(journal_entry :: JournalEntry.t(), attrs :: map()) ::
130 Ecto.Changeset.t()
131 def change_journal_entry(%JournalEntry{} = journal_entry, attrs \\ %{}) do
132 0 JournalEntry.changeset(journal_entry, attrs)
133 end
134
135 alias Klepsidra.Journals.JournalEntryTypes
136
137 @doc """
138 Returns the list of journal_entry_types.
139
140 ## Examples
141
142 iex> list_journal_entry_types()
143 [%JournalEntryTypes{}, ...]
144
145 """
146 @spec list_journal_entries() :: [JournalEntryTypes.t(), ...]
147 def list_journal_entry_types do
148 5 Repo.all(JournalEntryTypes)
149 end
150
151 @doc """
152 Gets a single journal_entry_types.
153
154 Raises `Ecto.NoResultsError` if the Journal entry types does not exist.
155
156 ## Examples
157
158 iex> get_journal_entry_types!(123)
159 %JournalEntryTypes{}
160
161 iex> get_journal_entry_types!(456)
162 ** (Ecto.NoResultsError)
163
164 """
165 @spec get_journal_entry_types!(id :: Ecto.UUID.t()) :: JournalEntryTypes.t() | no_return()
166 8 def get_journal_entry_types!(id), do: Repo.get!(JournalEntryTypes, id)
167
168 @doc """
169 Creates a journal_entry_types.
170
171 ## Examples
172
173 iex> create_journal_entry_types(%{field: value})
174 {:ok, %JournalEntryTypes{}}
175
176 iex> create_journal_entry_types(%{field: bad_value})
177 {:error, Ecto.Changeset.t()}
178
179 """
180 @spec create_journal_entry_types(attrs :: map()) ::
181 {:ok, JournalEntryTypes.t()} | {:error, Ecto.Changeset.t()}
182 def create_journal_entry_types(attrs \\ %{}) do
183 %JournalEntryTypes{}
184 |> JournalEntryTypes.changeset(attrs)
185 12 |> Repo.insert()
186 end
187
188 @doc """
189 Updates a journal_entry_types.
190
191 ## Examples
192
193 iex> update_journal_entry_types(journal_entry_types, %{field: new_value})
194 {:ok, %JournalEntryTypes{}}
195
196 iex> update_journal_entry_types(journal_entry_types, %{field: bad_value})
197 {:error, Ecto.Changeset.t()}
198
199 """
200 @spec update_journal_entry_types(
201 journal_entry_types :: JournalEntryTypes.t(),
202 attrs :: map()
203 ) ::
204 {:ok, JournalEntryTypes.t()} | {:error, Ecto.Changeset.t()}
205 def update_journal_entry_types(%JournalEntryTypes{} = journal_entry_types, attrs) do
206 journal_entry_types
207 |> JournalEntryTypes.changeset(attrs)
208 2 |> Repo.update()
209 end
210
211 @doc """
212 Deletes a journal_entry_types.
213
214 ## Examples
215
216 iex> delete_journal_entry_types(journal_entry_types)
217 {:ok, %JournalEntryTypes{}}
218
219 iex> delete_journal_entry_types(journal_entry_types)
220 {:error, Ecto.Changeset.t()}
221
222 """
223 @spec delete_journal_entry_types(journal_entry_types :: JournalEntryTypes.t()) ::
224 {:ok, JournalEntryTypes.t()} | {:error, Ecto.Changeset.t()}
225 def delete_journal_entry_types(%JournalEntryTypes{} = journal_entry_types) do
226 1 Repo.delete(journal_entry_types)
227 end
228
229 @doc """
230 Returns an `Ecto.Changeset.t()` for tracking journal_entry_types changes.
231
232 ## Examples
233
234 iex> change_journal_entry_types(journal_entry_types)
235 %Ecto.Changeset{data: %JournalEntryTypes{}}
236
237 """
238 @spec change_journal_entry_types(journal_entry_types :: JournalEntryTypes.t(), attrs :: map()) ::
239 Ecto.Changeset.t()
240 def change_journal_entry_types(%JournalEntryTypes{} = journal_entry_types, attrs \\ %{}) do
241 3 JournalEntryTypes.changeset(journal_entry_types, attrs)
242 end
243 end

lib/klepsidra/journals/journal_entry.ex

0.0
5
0
5
Line Hits Source
0 defmodule Klepsidra.Journals.JournalEntry do
1 @moduledoc """
2 Defines the `journal_entries` schema needed to record a generic set of journaling
3 needs, from the deeply personal, to commercial.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 @primary_key {:id, Ecto.UUID, autogenerate: true}
10 @foreign_key_type Ecto.UUID
11
12 @type t :: %__MODULE__{
13 journal_for: String.t(),
14 entry_text_markdown: String.t(),
15 entry_text_html: String.t(),
16 highlights: String.t(),
17 entry_type_id: Ecto.UUID.t(),
18 location_id: Ecto.UUID.t(),
19 latitude: float(),
20 longitude: float(),
21 mood: String.t(),
22 is_private: boolean(),
23 is_short_entry: boolean(),
24 is_scheduled: boolean(),
25 user_id: integer()
26 }
27 0 schema "journal_entries" do
28 field(:journal_for, :string)
29 field(:entry_text_markdown, :string)
30 field(:entry_text_html, :string)
31 field(:highlights, :string)
32 belongs_to(:entry_type, Klepsidra.Journals.JournalEntryTypes)
33 belongs_to(:location, Klepsidra.Locations.City)
34 field(:latitude, :float, default: nil)
35 field(:longitude, :float, default: nil)
36 field(:mood, :string, default: "")
37 field(:is_private, :boolean, default: false)
38 field(:is_short_entry, :boolean, default: false)
39 field(:is_scheduled, :boolean, default: false)
40 belongs_to(:user, Klepsidra.Accounts.User)
41
42 many_to_many(:tags, Klepsidra.Categorisation.Tag,
43 join_through: "journal_entry_tags",
44 on_replace: :delete,
45 preload_order: [asc: :name]
46 )
47
48 timestamps()
49 end
50
51 @doc false
52 def changeset(journal_entry, attrs) do
53 journal_entry
54 |> cast(attrs, [
55 :journal_for,
56 :entry_text_markdown,
57 :entry_text_html,
58 :entry_type_id,
59 :highlights,
60 :location_id,
61 :latitude,
62 :longitude,
63 :mood,
64 :is_private,
65 :is_short_entry,
66 :is_scheduled,
67 :user_id
68 ])
69 |> generate_html_entry()
70 |> validate_required(:journal_for, message: "Enter the date this journal is for")
71 |> validate_required(:entry_text_html, message: "You must write your journal entry")
72 |> validate_required(:entry_type_id,
73 message: "Please select what type of journal entry you're logging"
74 )
75 |> assoc_constraint(:entry_type)
76 |> validate_required(:location_id,
77 message: "Where are you as you log this entry?"
78 )
79 0 |> assoc_constraint(:location)
80 end
81
82 @doc """
83 Early in the validation chain, ensuring that validity of all necessary fields
84 hasn't been checked yet, convert all text written in the markdown field to clean
85 HTML.
86 """
87 def generate_html_entry(
88 %{valid?: true, changes: %{entry_text_markdown: entry_text_markdown}} = changeset
89 ) do
90 0 put_change(changeset, :entry_text_html, convert_markdown_to_html(entry_text_markdown))
91 end
92
93 0 def generate_html_entry(changeset), do: changeset
94
95 @doc """
96 Take in markdown-formatted text, converting it to HTML.
97 """
98 def convert_markdown_to_html(markdown_string) when is_bitstring(markdown_string) do
99 Earmark.as_html!(markdown_string,
100 breaks: true,
101 code_class_prefix: "lang- language-",
102 compact_output: false,
103 escape: false,
104 footnotes: true,
105 gfm_tables: true,
106 smartypants: true,
107 sub_sup: true
108 )
109 0 |> HtmlSanitizeEx.html5()
110 end
111 end

lib/klepsidra/journals/journal_entry_types.ex

50.0
4
197
2
Line Hits Source
0 defmodule Klepsidra.Journals.JournalEntryTypes do
1 @moduledoc """
2 Defines the `JournalEntryTypes` schema and functions needed to categorise the
3 type of journal entries recorded.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 @primary_key {:id, Ecto.UUID, autogenerate: true}
10 @foreign_key_type Ecto.UUID
11
12 @type t :: %__MODULE__{
13 name: String.t(),
14 description: String.t(),
15 active: boolean()
16 }
17 180 schema "journal_entry_types" do
18 field :name, :string
19 field :description, :string
20 field :active, :boolean, default: true
21
22 timestamps()
23 end
24
25 @doc false
26 def changeset(journal_entry_types, attrs) do
27 journal_entry_types
28 |> cast(attrs, [:name, :description, :active])
29 |> validate_required(:name, message: "Enter a journal entry type")
30 17 |> unique_constraint(:name,
31 message: "A journal entry type with this name already exists"
32 )
33 end
34
35 @doc """
36 Used across live components to populate select options with journal entry types.
37 """
38 @spec populate_entry_types_list() :: [Klepsidra.Journals.JournalEntryTypes.t(), ...]
39 0 def populate_entry_types_list() do
40 [
41 {"", ""}
42 | Klepsidra.Journals.list_journal_entry_types()
43 0 |> Enum.map(fn entry_type -> {entry_type.name, entry_type.id} end)
44 ]
45 end
46 end

lib/klepsidra/localisation.ex

100.0
6
16
0
Line Hits Source
0 defmodule Klepsidra.Localisation do
1 @moduledoc """
2 The Localisation context.
3 """
4
5 import Ecto.Query, warn: false
6 alias Klepsidra.Repo
7
8 alias Klepsidra.Localisation.Language
9
10 @doc """
11 Returns the list of localisation_languages.
12
13 ## Examples
14
15 iex> list_localisation_languages()
16 [%Language{}, ...]
17
18 """
19 def list_localisation_languages do
20 1 Repo.all(Language)
21 end
22
23 @doc """
24 Gets a single language.
25
26 Raises `Ecto.NoResultsError` if the Language does not exist.
27
28 ## Examples
29
30 iex> get_language!(123)
31 %Language{}
32
33 iex> get_language!(456)
34 ** (Ecto.NoResultsError)
35
36 """
37 def get_language!(language_code) do
38 3 Language |> where([l], l."iso_639-3_language_code" == ^language_code) |> Repo.one()
39 end
40
41 @doc """
42 Creates a language.
43
44 ## Examples
45
46 iex> create_language(%{field: value})
47 {:ok, %Language{}}
48
49 iex> create_language(%{field: bad_value})
50 {:error, %Ecto.Changeset{}}
51
52 """
53 def create_language(attrs \\ %{}) do
54 %Language{}
55 |> Language.changeset(attrs)
56 8 |> Repo.insert()
57 end
58
59 @doc """
60 Updates a language.
61
62 ## Examples
63
64 iex> update_language(language, %{field: new_value})
65 {:ok, %Language{}}
66
67 iex> update_language(language, %{field: bad_value})
68 {:error, %Ecto.Changeset{}}
69
70 """
71 def update_language(%Language{} = language, attrs) do
72 language
73 |> Language.changeset(attrs)
74 2 |> Repo.update()
75 end
76
77 @doc """
78 Deletes a language.
79
80 ## Examples
81
82 iex> delete_language(language)
83 {:ok, %Language{}}
84
85 iex> delete_language(language)
86 {:error, %Ecto.Changeset{}}
87
88 """
89 def delete_language(%Language{} = language) do
90 1 Repo.delete(language)
91 end
92
93 @doc """
94 Returns an `%Ecto.Changeset{}` for tracking language changes.
95
96 ## Examples
97
98 iex> change_language(language)
99 %Ecto.Changeset{data: %Language{}}
100
101 """
102 def change_language(%Language{} = language, attrs \\ %{}) do
103 1 Language.changeset(language, attrs)
104 end
105 end

lib/klepsidra/localisation/language.ex

100.0
2
115
0
Line Hits Source
0 defmodule Klepsidra.Localisation.Language do
1 @moduledoc """
2 Defines a schema for the `Languages` entity, listing the languages of the world.
3
4 This is not meant to be a user-editable entity, imported on a periodic basis
5 from the [Geonames](https://geonames.org) project, specifically the
6 `iso-languagecodes.txt` file, all languages' information, with the file
7 annotation headers stripped off and column headers converted to lowercase,
8 underscore-separated names.
9 """
10
11 use Ecto.Schema
12 import Ecto.Changeset
13
14 @primary_key false
15 @type t :: %__MODULE__{
16 "iso_639-1_language_code": String.t(),
17 "iso_639-2_language_code": String.t(),
18 "iso_639-3_language_code": String.t(),
19 language_name: String.t()
20 }
21 104 schema "localisation_languages" do
22 field(:"iso_639-3_language_code", :string, primary_key: true)
23 field(:"iso_639-2_language_code", :string)
24 field(:"iso_639-1_language_code", :string)
25 field(:language_name, :string)
26
27 timestamps()
28 end
29
30 @doc false
31 def changeset(language, attrs) do
32 language
33 |> cast(attrs, [
34 :"iso_639-3_language_code",
35 :"iso_639-2_language_code",
36 :"iso_639-1_language_code",
37 :language_name
38 ])
39 |> unique_constraint(:"iso_639-3")
40 11 |> validate_required([:"iso_639-3_language_code", :language_name])
41 end
42 end

lib/klepsidra/locations.ex

18.7
64
32
52
Line Hits Source
0 defmodule Klepsidra.Locations do
1 @moduledoc """
2 Location utilities for countries, subdivisions and cities.
3
4 The module is largely a wrapper for Plausible Analytics'
5 [`Location` package](https://github.com/plausible/location/tree/main).
6 """
7
8 import Ecto.Query, warn: false
9 alias Klepsidra.Repo
10
11 alias Klepsidra.Locations.FeatureClass
12 alias Klepsidra.Locations.FeatureCode
13 alias Klepsidra.Locations.Continent
14 alias Klepsidra.Locations.Country
15 alias Klepsidra.Locations.AdministrativeDivisions1
16 alias Klepsidra.Locations.AdministrativeDivisions2
17 alias Klepsidra.Locations.City
18
19 @doc """
20 Returns the list of locations_feature_classes.
21
22 ## Examples
23
24 iex> list_feature_classes()
25 [%FeatureClass{}, ...]
26
27 """
28 def list_feature_classes do
29 1 Repo.all(FeatureClass)
30 end
31
32 @doc """
33 Gets a single feature_class.
34
35 Raises `Ecto.NoResultsError` if the Feature class does not exist.
36
37 ## Examples
38
39 iex> get_feature_class!(123)
40 %FeatureClass{}
41
42 iex> get_feature_class!(456)
43 ** (Ecto.NoResultsError)
44
45 """
46 def get_feature_class!(feature_class) do
47 3 FeatureClass |> where([fc], fc.feature_class == ^feature_class) |> Repo.one()
48 end
49
50 @doc """
51 Creates a feature_class.
52
53 ## Examples
54
55 iex> create_feature_class(%{field: value})
56 {:ok, %FeatureClass{}}
57
58 iex> create_feature_class(%{field: bad_value})
59 {:error, %Ecto.Changeset{}}
60
61 """
62 def create_feature_class(attrs \\ %{}) do
63 %FeatureClass{}
64 |> FeatureClass.changeset(attrs)
65 8 |> Repo.insert()
66 end
67
68 @doc """
69 Updates a feature_class.
70
71 ## Examples
72
73 iex> update_feature_class(feature_class, %{field: new_value})
74 {:ok, %FeatureClass{}}
75
76 iex> update_feature_class(feature_class, %{field: bad_value})
77 {:error, %Ecto.Changeset{}}
78
79 """
80 def update_feature_class(%FeatureClass{} = feature_class, attrs) do
81 feature_class
82 |> FeatureClass.changeset(attrs)
83 2 |> Repo.update()
84 end
85
86 @doc """
87 Deletes a feature_class.
88
89 ## Examples
90
91 iex> delete_feature_class(feature_class)
92 {:ok, %FeatureClass{}}
93
94 iex> delete_feature_class(feature_class)
95 {:error, %Ecto.Changeset{}}
96
97 """
98 def delete_feature_class(%FeatureClass{} = feature_class) do
99 1 Repo.delete(feature_class)
100 end
101
102 @doc """
103 Returns an `%Ecto.Changeset{}` for tracking feature_class changes.
104
105 ## Examples
106
107 iex> change_feature_class(feature_class)
108 %Ecto.Changeset{data: %FeatureClass{}}
109
110 """
111 def change_feature_class(%FeatureClass{} = feature_class, attrs \\ %{}) do
112 1 FeatureClass.changeset(feature_class, attrs)
113 end
114
115 @doc """
116 Returns the list of feature_codes.
117
118 ## Examples
119
120 iex> list_feature_codes()
121 [%FeatureCode{}, ...]
122
123 """
124 def list_feature_codes do
125 0 Repo.all(FeatureCode)
126 end
127
128 @doc """
129 Gets a single feature_code.
130
131 Raises `Ecto.NoResultsError` if the Feature code does not exist.
132
133 ## Examples
134
135 iex> get_feature_code!("P", "PPL")
136 %FeatureCode{}
137
138 iex> get_feature_code!("X", "YYY")
139 ** (Ecto.NoResultsError)
140
141 """
142 def get_feature_code!(feature_code) do
143 FeatureCode
144 0 |> where([fc], fc.feature_code == ^feature_code)
145 0 |> Repo.one()
146 end
147
148 @doc """
149 Creates a feature_code.
150
151 ## Examples
152
153 iex> create_feature_code(%{field: value})
154 {:ok, %FeatureCode{}}
155
156 iex> create_feature_code(%{field: bad_value})
157 {:error, %Ecto.Changeset{}}
158
159 """
160 def create_feature_code(attrs \\ %{}) do
161 %FeatureCode{}
162 |> FeatureCode.changeset(attrs)
163 0 |> Repo.insert()
164 end
165
166 @doc """
167 Updates a feature_code.
168
169 ## Examples
170
171 iex> update_feature_code(feature_code, %{field: new_value})
172 {:ok, %FeatureCode{}}
173
174 iex> update_feature_code(feature_code, %{field: bad_value})
175 {:error, %Ecto.Changeset{}}
176
177 """
178 def update_feature_code(%FeatureCode{} = feature_code, attrs) do
179 feature_code
180 |> FeatureCode.changeset(attrs)
181 0 |> Repo.update()
182 end
183
184 @doc """
185 Deletes a feature_code.
186
187 ## Examples
188
189 iex> delete_feature_code(feature_code)
190 {:ok, %FeatureCode{}}
191
192 iex> delete_feature_code(feature_code)
193 {:error, %Ecto.Changeset{}}
194
195 """
196 def delete_feature_code(%FeatureCode{} = feature_code) do
197 0 Repo.delete(feature_code)
198 end
199
200 @doc """
201 Returns an `%Ecto.Changeset{}` for tracking feature_code changes.
202
203 ## Examples
204
205 iex> change_feature_code(feature_code)
206 %Ecto.Changeset{data: %FeatureCode{}}
207
208 """
209 def change_feature_code(%FeatureCode{} = feature_code, attrs \\ %{}) do
210 0 FeatureCode.changeset(feature_code, attrs)
211 end
212
213 @doc """
214 Returns the list of locations_continents.
215
216 ## Examples
217
218 iex> list_continents()
219 [%Continent{}, ...]
220
221 """
222 def list_continents do
223 1 Repo.all(Continent)
224 end
225
226 @doc """
227 Gets a single continent.
228
229 Raises `Ecto.NoResultsError` if the Continent does not exist.
230
231 ## Examples
232
233 iex> get_continent!(123)
234 %Continent{}
235
236 iex> get_continent!(456)
237 ** (Ecto.NoResultsError)
238
239 """
240 def get_continent!(continent_code) do
241 3 Continent |> where([c], c.continent_code == ^continent_code) |> Repo.one()
242 end
243
244 @doc """
245 Creates a continent.
246
247 ## Examples
248
249 iex> create_continent(%{field: value})
250 {:ok, %Continent{}}
251
252 iex> create_continent(%{field: bad_value})
253 {:error, %Ecto.Changeset{}}
254
255 """
256 def create_continent(attrs \\ %{}) do
257 %Continent{}
258 |> Continent.changeset(attrs)
259 8 |> Repo.insert()
260 end
261
262 @doc """
263 Updates a continent.
264
265 ## Examples
266
267 iex> update_continent(continent, %{field: new_value})
268 {:ok, %Continent{}}
269
270 iex> update_continent(continent, %{field: bad_value})
271 {:error, %Ecto.Changeset{}}
272
273 """
274 def update_continent(%Continent{} = continent, attrs) do
275 continent
276 |> Continent.changeset(attrs)
277 2 |> Repo.update()
278 end
279
280 @doc """
281 Deletes a continent.
282
283 ## Examples
284
285 iex> delete_continent(continent)
286 {:ok, %Continent{}}
287
288 iex> delete_continent(continent)
289 {:error, %Ecto.Changeset{}}
290
291 """
292 def delete_continent(%Continent{} = continent) do
293 1 Repo.delete(continent)
294 end
295
296 @doc """
297 Returns an `%Ecto.Changeset{}` for tracking continent changes.
298
299 ## Examples
300
301 iex> change_continent(continent)
302 %Ecto.Changeset{data: %Continent{}}
303
304 """
305 def change_continent(%Continent{} = continent, attrs \\ %{}) do
306 1 Continent.changeset(continent, attrs)
307 end
308
309 @doc """
310 Returns the list of countries.
311
312 ## Examples
313
314 iex> list_countries()
315 [%Country{}, ...]
316
317 """
318 def list_countries do
319 0 Repo.all(Country)
320 end
321
322 @doc """
323 Gets a single country.
324
325 Raises `Ecto.NoResultsError` if the Country does not exist.
326
327 ## Examples
328
329 iex> get_country!(123)
330 %Country{}
331
332 iex> get_country!(456)
333 ** (Ecto.NoResultsError)
334
335 """
336 def get_country!(iso_country_code) do
337 Country
338 0 |> where([c], c.iso_country_code == ^iso_country_code)
339 0 |> Repo.one()
340 end
341
342 @doc """
343 Creates a country.
344
345 ## Examples
346
347 iex> create_country(%{field: value})
348 {:ok, %Country{}}
349
350 iex> create_country(%{field: bad_value})
351 {:error, %Ecto.Changeset{}}
352
353 """
354 def create_country(attrs \\ %{}) do
355 %Country{}
356 |> Country.changeset(attrs)
357 0 |> Repo.insert()
358 end
359
360 @doc """
361 Updates a country.
362
363 ## Examples
364
365 iex> update_country(country, %{field: new_value})
366 {:ok, %Country{}}
367
368 iex> update_country(country, %{field: bad_value})
369 {:error, %Ecto.Changeset{}}
370
371 """
372 def update_country(%Country{} = country, attrs) do
373 country
374 |> Country.changeset(attrs)
375 0 |> Repo.update()
376 end
377
378 @doc """
379 Deletes a country.
380
381 ## Examples
382
383 iex> delete_country(country)
384 {:ok, %Country{}}
385
386 iex> delete_country(country)
387 {:error, %Ecto.Changeset{}}
388
389 """
390 def delete_country(%Country{} = country) do
391 0 Repo.delete(country)
392 end
393
394 @doc """
395 Returns an `%Ecto.Changeset{}` for tracking country changes.
396
397 ## Examples
398
399 iex> change_country(country)
400 %Ecto.Changeset{data: %Country{}}
401
402 """
403 def change_country(%Country{} = country, attrs \\ %{}) do
404 0 Country.changeset(country, attrs)
405 end
406
407 @doc """
408 Returns the list of level 1 administrative divisions.
409
410 ## Examples
411
412 iex> list_administrative_divisions_1()
413 [%AdministrativeDivisions1{}, ...]
414
415 """
416 def list_administrative_divisions_1 do
417 0 Repo.all(AdministrativeDivisions1)
418 end
419
420 @doc """
421 Gets a single level 1 administrative division.
422
423 Raises `Ecto.NoResultsError` if the `administrative_division_1` does not exist.
424
425 ## Examples
426
427 iex> get_administrative_division_1!(123)
428 %AdministrativeDivisions1{}
429
430 iex> get_administrative_division_1!(456)
431 ** (Ecto.NoResultsError)
432
433 """
434 def get_administrative_division_1!(administrative_division_1_code) do
435 AdministrativeDivisions1
436 0 |> where([ad1], ad1.administrative_division_1_code == ^administrative_division_1_code)
437 0 |> Repo.one()
438 end
439
440 @doc """
441 Creates a new level 1 administrative division.
442
443 ## Examples
444
445 iex> create_administrative_division_1(%{field: value})
446 {:ok, %AdministrativeDivisions1{}}
447
448 iex> create_administrative_division_1(%{field: bad_value})
449 {:error, %Ecto.Changeset{}}
450
451 """
452 def create_administrative_division_1(attrs \\ %{}) do
453 %AdministrativeDivisions1{}
454 |> AdministrativeDivisions1.changeset(attrs)
455 0 |> Repo.insert()
456 end
457
458 @doc """
459 Updates a level 1 administrative division.
460
461 ## Examples
462
463 iex> update_administrative_division_1(administrative_division_1, %{field: new_value})
464 {:ok, %AdministrativeDivisions1{}}
465
466 iex> update_administrative_division_1(administrative_division_1, %{field: bad_value})
467 {:error, %Ecto.Changeset{}}
468
469 """
470 def update_administrative_division_1(
471 %AdministrativeDivisions1{} = administrative_division_1,
472 attrs
473 ) do
474 administrative_division_1
475 |> AdministrativeDivisions1.changeset(attrs)
476 0 |> Repo.update()
477 end
478
479 @doc """
480 Deletes a level 1 administrative division.
481
482 ## Examples
483
484 iex> delete_administrative_division_1(administrative_division_1)
485 {:ok, %AdministrativeDivisions1{}}
486
487 iex> delete_administrative_division_1(administrative_division_1)
488 {:error, %Ecto.Changeset{}}
489
490 """
491 def delete_administrative_division_1(%AdministrativeDivisions1{} = administrative_division_1) do
492 0 Repo.delete(administrative_division_1)
493 end
494
495 @doc """
496 Returns an `%Ecto.Changeset{}` for tracking `administrative_divisions_1` changes.
497
498 ## Examples
499
500 iex> change_administrative_division_1(administrative_division_1)
501 %Ecto.Changeset{data: %AdministrativeDivisions1{}}
502
503 """
504 def change_administrative_division_1(
505 %AdministrativeDivisions1{} = administrative_division_1,
506 0 attrs \\ %{}
507 ) do
508 0 AdministrativeDivisions1.changeset(administrative_division_1, attrs)
509 end
510
511 @doc """
512 Returns the list of level 2 administrative divisions.
513
514 ## Examples
515
516 iex> list_locations_administrative_divisions_2()
517 [%AdministrativeDivisions2{}, ...]
518
519 """
520 def list_locations_administrative_divisions_2 do
521 0 Repo.all(AdministrativeDivisions2)
522 end
523
524 @doc """
525 Gets a single level 2 administrative division.
526
527 Raises `Ecto.NoResultsError` if the `administrative_division_2` does not exist.
528
529 ## Examples
530
531 iex> get_administrative_division_2!(123)
532 %AdministrativeDivisions2{}
533
534 iex> get_administrative_division_2!(456)
535 ** (Ecto.NoResultsError)
536
537 """
538 def get_administrative_division_2!(administrative_division_2_code) do
539 AdministrativeDivisions2
540 0 |> where([ad2], ad2.administrative_division_2_code == ^administrative_division_2_code)
541 0 |> Repo.one()
542 end
543
544 @doc """
545 Creates a level 2 administrative division.
546
547 ## Examples
548
549 iex> create_administrative_division_2(%{field: value})
550 {:ok, %AdministrativeDivisions2{}}
551
552 iex> create_administrative_division_2(%{field: bad_value})
553 {:error, %Ecto.Changeset{}}
554
555 """
556 def create_administrative_division_2(attrs \\ %{}) do
557 %AdministrativeDivisions2{}
558 |> AdministrativeDivisions2.changeset(attrs)
559 0 |> Repo.insert()
560 end
561
562 @doc """
563 Updates a level 2 administrative division.
564
565 ## Examples
566
567 iex> update_administrative_division_2(administrative_division_2, %{field: new_value})
568 {:ok, %AdministrativeDivisions2{}}
569
570 iex> update_administrative_division_2(administrative_division_2, %{field: bad_value})
571 {:error, %Ecto.Changeset{}}
572
573 """
574 def update_administrative_division_2(
575 %AdministrativeDivisions2{} = administrative_division_2,
576 attrs
577 ) do
578 administrative_division_2
579 |> AdministrativeDivisions2.changeset(attrs)
580 0 |> Repo.update()
581 end
582
583 @doc """
584 Deletes a level 2 administrative division.
585
586 ## Examples
587
588 iex> delete_administrative_division_2(administrative_division_2)
589 {:ok, %AdministrativeDivisions2{}}
590
591 iex> delete_administrative_division_2(administrative_division_2)
592 {:error, %Ecto.Changeset{}}
593
594 """
595 def delete_administrative_division_2(%AdministrativeDivisions2{} = administrative_division_2) do
596 0 Repo.delete(administrative_division_2)
597 end
598
599 @doc """
600 Returns an `%Ecto.Changeset{}` for tracking `administrative_division_2` changes.
601
602 ## Examples
603
604 iex> change_administrative_division_2(administrative_division_2)
605 %Ecto.Changeset{data: %AdministrativeDivision2{}}
606
607 """
608 def change_administrative_division_2(
609 %AdministrativeDivisions2{} = administrative_division_2,
610 0 attrs \\ %{}
611 ) do
612 0 AdministrativeDivisions2.changeset(administrative_division_2, attrs)
613 end
614
615 @doc """
616 Returns the list of cities.
617
618 ## Examples
619
620 iex> Locations.list_cities()
621 [%Locations.City{}, ...]
622
623 """
624 def list_cities do
625 0 Repo.all(City)
626 end
627
628 @doc """
629 Returns a list of cities containing the name fragment, sorted by
630 descending population size, and by name in alphabetical order.
631
632 ## Examples
633
634 iex> Locations.list_cities_by_name("")
635 []
636
637 iex> Locations.list_cities_by_name("london")
638 [%{}, ...]
639 """
640 @spec list_cities_by_name(name_filter :: String.t(), options :: keyword()) :: [map(), ...]
641 0 def list_cities_by_name(name_filter, options \\ [])
642
643 0 def list_cities_by_name("", _), do: []
644
645 def list_cities_by_name(name_filter, _options) when is_bitstring(name_filter) do
646 # Keyword.get(options, :limit, 25)
647 0 max_result_count = 25
648 0 like_name = "%#{name_filter}%"
649
650 0 query =
651 0 from(
652 c in City,
653 left_join: fc in FeatureCode,
654 on: c.feature_code == fc.feature_code,
655 left_join: co in Country,
656 on: c.country_code == co.iso_country_code,
657 where: like(c.ascii_name, ^like_name),
658 left_join: ad in AdministrativeDivisions1,
659 on: c.administrative_division_1_code == ad.administrative_division_1_code,
660 order_by: [
661 asc: fc.order,
662 desc: c.population,
663 desc: co.population,
664 desc: co.area,
665 asc: c.name
666 ],
667 select: %{
668 id: c.id,
669 name: c.name,
670 level_1_division: ad.administrative_division_1_name |> coalesce(""),
671 country_name: co.country_name,
672 feature_description: fc.description
673 },
674 limit: ^max_result_count
675 )
676
677 0 Repo.all(query)
678 end
679
680 0 def list_cities_by_name(_, _), do: []
681
682 @doc """
683 Searches for cities, forming the result into an HTML usable
684 %{value: id, label: city} map.
685 """
686 @spec city_search(city_name :: String.t()) :: [map()]
687 def city_search(city_name) when is_bitstring(city_name) do
688 city_name
689 |> list_cities_by_name(limit: 25)
690 0 |> Enum.map(fn city ->
691 0 %{value: city.id, label: "#{city.name}, #{city.level_1_division} - #{city.country_name}"}
692 end)
693 end
694
695 0 def city_search(_name), do: []
696
697 @doc """
698 Gets a single city.
699
700 Raises `Ecto.NoResultsError` if the City does not exist.
701
702 ## Examples
703
704 iex> Locations.get_city!(123)
705 %Locations.City{}
706
707 iex> Locations.get_city!(456)
708 ** (Ecto.NoResultsError)
709
710 """
711 0 def get_city!(id), do: Repo.get!(City, id)
712
713 @doc """
714 Gets a single city, with level 1 administrative division or territory,
715 and country name.
716
717 Returns `nil` if the City does not exist.
718
719 ## Examples
720
721 iex> Locations.get_city_territory_and_country!(123)
722 %Locations.City{}
723
724 iex> Locations.get_city_territory_and_country!(456)
725 nil
726
727 """
728 @spec get_city_territory_and_country!(city_id :: Ecto.UUID.t()) :: City.t() | nil
729 0 def get_city_territory_and_country!(""), do: nil
730
731 def get_city_territory_and_country!(city_id) when is_bitstring(city_id) do
732 0 query =
733 0 from(
734 c in City,
735 left_join: fc in FeatureCode,
736 on: c.feature_code == fc.feature_code,
737 left_join: co in Country,
738 on: c.country_code == co.iso_country_code,
739 left_join: ad in AdministrativeDivisions1,
740 on: c.administrative_division_1_code == ad.administrative_division_1_code,
741 where: c.id == ^city_id,
742 select: %{
743 id: c.id,
744 name: c.name,
745 level_1_division: ad.administrative_division_1_name |> coalesce(""),
746 country_name: co.country_name,
747 feature_description: fc.description
748 }
749 )
750
751 0 Repo.one(query)
752 end
753
754 0 def get_city_territory_and_country!(_), do: nil
755
756 @doc """
757 Creates a city.
758
759 ## Examples
760
761 iex> Locations.create_city(%{name: "Londinium"})
762 {:ok, %Locations.City{}}
763
764 iex> Locations.create_city(%{name: 123_456})
765 {:error, %Ecto.Changeset{}}
766
767 """
768 def create_city(attrs \\ %{}) do
769 %City{}
770 |> City.changeset(attrs)
771 0 |> Repo.insert()
772 end
773
774 @doc """
775 Updates a city.
776
777 ## Examples
778
779 iex> Locations.update_city(city, %{name: "Troy"})
780 {:ok, %Locations.City{}}
781
782 iex> Locations.update_city(city, %{field: 300})
783 {:error, %Ecto.Changeset{}}
784
785 """
786 def update_city(%City{} = city, attrs) do
787 city
788 |> City.changeset(attrs)
789 0 |> Repo.update()
790 end
791
792 @doc """
793 Deletes a city.
794
795 ## Examples
796
797 iex> Locations.delete_city(city)
798 {:ok, %Locations.City{}}
799
800 iex> Locations.delete_city(city)
801 {:error, %Ecto.Changeset{}}
802
803 """
804 def delete_city(%City{} = city) do
805 0 Repo.delete(city)
806 end
807
808 @doc """
809 Returns an `%Ecto.Changeset{}` for tracking city changes.
810
811 ## Examples
812
813 iex> Locations.change_city(city)
814 %Ecto.Changeset{data: %Locations.City{}}
815
816 """
817 def change_city(%City{} = city, attrs \\ %{}) do
818 0 City.changeset(city, attrs)
819 end
820 end

lib/klepsidra/locations/administrative_divisions_1.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.Locations.AdministrativeDivisions1 do
1 @moduledoc """
2 Defines a schema for the `AdministrativeDivision1` entity, listing GeoNames'
3 country code and administrative division 1 codes, data that is used in
4 their cities database.
5
6 This is not meant to be a user-editable entity, imported on a periodic basis
7 from the [Geonames](https://geonames.org) project, specifically the
8 `admin1CodesASCII.txt` file, with column headers inserted.
9 """
10
11 use Ecto.Schema
12 import Ecto.Changeset
13
14 @primary_key false
15 @type t :: %__MODULE__{
16 administrative_division_1_code: String.t(),
17 country_code: String.t(),
18 administrative_division_1_name: String.t(),
19 administrative_division_1_ascii_name: String.t(),
20 geoname_id: integer()
21 }
22 0 schema "locations_administrative_divisions_1" do
23 field(:administrative_division_1_code, :string, primary_key: true)
24 field(:country_code, :string)
25 field(:administrative_division_1_name, :string)
26 field(:administrative_division_1_ascii_name, :string)
27 field(:geoname_id, :integer)
28
29 timestamps()
30 end
31
32 @doc false
33 def changeset(administrative_divisions_1, attrs) do
34 administrative_divisions_1
35 |> cast(attrs, [
36 :administrative_division_1_code,
37 :country_code,
38 :administrative_division_1_name,
39 :administrative_division_1_ascii_name,
40 :geoname_id
41 ])
42 |> unique_constraint(:administrative_division_1_code)
43 |> foreign_key_constraint(:country_code)
44 0 |> validate_required([
45 :administrative_division_1_code,
46 :country_code,
47 :administrative_division_1_name,
48 :administrative_division_1_ascii_name,
49 :geoname_id
50 ])
51 end
52 end

lib/klepsidra/locations/administrative_divisions_2.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.Locations.AdministrativeDivisions2 do
1 @moduledoc """
2 Defines a schema for the `AdministrativeDivision2` entity, listing GeoNames'
3 country code, administrative division 1, and administrative division 2 codes,
4 data that is used in their cities database.
5
6 This is not meant to be a user-editable entity, imported on a periodic basis
7 from the [Geonames](https://geonames.org) project, specifically the
8 `admin2Codes.txt` file, with column headers inserted.
9 """
10
11 use Ecto.Schema
12 import Ecto.Changeset
13
14 @primary_key false
15 @type t :: %__MODULE__{
16 administrative_division_2_code: String.t(),
17 administrative_division_1_code: String.t(),
18 country_code: String.t(),
19 administrative_division_2_name: String.t(),
20 administrative_division_2_ascii_name: String.t(),
21 geoname_id: integer()
22 }
23 0 schema "locations_administrative_divisions_2" do
24 field(:administrative_division_2_code, :string, primary_key: true)
25 field(:administrative_division_1_code, :string)
26 field(:country_code, :string)
27 field(:administrative_division_2_name, :string)
28 field(:administrative_division_2_ascii_name, :string)
29 field(:geoname_id, :integer)
30
31 timestamps()
32 end
33
34 @doc false
35 def changeset(administrative_division2, attrs) do
36 administrative_division2
37 |> cast(attrs, [
38 :administrative_division_2_code,
39 :administrative_division_1_code,
40 :country_code,
41 :administrative_division_2_name,
42 :administrative_division_2_ascii_name,
43 :geoname_id
44 ])
45 |> unique_constraint(:administrative_division_2_code)
46 |> foreign_key_constraint(:administrative_division_1_code)
47 |> foreign_key_constraint(:country_code)
48 0 |> validate_required([
49 :administrative_division_2_code,
50 :administrative_division_1_code,
51 :country_code,
52 :administrative_division_2_name,
53 :administrative_division_2_ascii_name,
54 :geoname_id
55 ])
56 end
57 end

lib/klepsidra/locations/city.ex

0.0
8
0
8
Line Hits Source
0 defmodule Klepsidra.Locations.City do
1 @moduledoc """
2 Defines a schema for the `City` entity, used to select cities of the world.
3
4 This is not meant to be a user-editable entity, imported on a periodic basis
5 from the [Geonames](https://geonames.org) project, specifically the `cities500.zip`
6 file, all cities with a population greater than 500.
7 """
8
9 use Ecto.Schema
10 import Ecto.Changeset
11 alias Klepsidra.Locations
12
13 @primary_key {:id, Ecto.UUID, autogenerate: true}
14 @foreign_key_type Ecto.UUID
15
16 @type t :: %__MODULE__{
17 id: Ecto.UUID.t(),
18 geoname_id: integer(),
19 name: String.t(),
20 ascii_name: String.t(),
21 alternate_names: String.t(),
22 latitude: float(),
23 longitude: float(),
24 feature_class: String.t(),
25 feature_code: String.t(),
26 country_code: String.t(),
27 cc2: String.t(),
28 administrative_division_1_code: String.t(),
29 administrative_division_2_code: String.t(),
30 administrative_division_3_code: String.t(),
31 administrative_division_4_code: String.t(),
32 population: integer(),
33 elevation: integer(),
34 dem: integer(),
35 timezone: String.t(),
36 modification_date: Date.t()
37 }
38 0 schema "locations_cities" do
39 field(:geoname_id, :integer)
40 field(:name, :string)
41 field(:ascii_name, :string)
42 field(:alternate_names, :string)
43 field(:latitude, :float)
44 field(:longitude, :float)
45
46 field(:feature_class, :binary_id)
47 field(:feature_code, :binary_id)
48
49 field(:country_code, :string)
50 field(:cc2, :string)
51 field(:administrative_division_1_code, :string)
52 field(:administrative_division_2_code, :string)
53 field(:administrative_division_3_code, :string)
54 field(:administrative_division_4_code, :string)
55 field(:population, :integer)
56 field(:elevation, :integer)
57 field(:dem, :integer)
58 field(:timezone, :string)
59 field(:modification_date, :date)
60
61 timestamps()
62 end
63
64 @doc false
65 def changeset(city, attrs) do
66 city
67 |> cast(attrs, [
68 :geoname_id,
69 :name,
70 :ascii_name,
71 :alternate_names,
72 :latitude,
73 :longitude,
74 :feature_class,
75 :feature_code,
76 :country_code,
77 :cc2,
78 :administrative_division_1_code,
79 :administrative_division_2_code,
80 :administrative_division_3_code,
81 :administrative_division_4_code,
82 :population,
83 :elevation,
84 :dem,
85 :timezone,
86 :modification_date
87 ])
88 |> unique_constraint(:geoname_id)
89 |> foreign_key_constraint(:feature_class,
90 name: :FK_locations_cities_locations_feature_classes_4
91 )
92 |> foreign_key_constraint(:feature_code, name: :FK_locations_cities_locations_feature_codes_5)
93 |> foreign_key_constraint(:country_code, name: :FK_locations_cities_locations_countries_3)
94 |> foreign_key_constraint(:administrative_divisions_1_code,
95 name: :FK_locations_cities_locations_administrative_divisions_1
96 )
97 0 |> validate_required([
98 :geoname_id,
99 :name,
100 :latitude,
101 :longitude,
102 :feature_class,
103 :feature_code,
104 :country_code,
105 :population,
106 :timezone,
107 :modification_date
108 ])
109 end
110
111 @doc """
112 Constructs an HTML `select` option for a single city entity, for use by
113 the `live_select` live component.
114
115 Given a current `location_id`, a foreign key reference to a city in the
116 `locations_cities` table, calls the `get_city_territory_and_country/1`
117 query, obtaining necessary fields to construct a full, unambiguous,
118 city name.
119
120 ## Returns
121
122 Returns a single map:
123 ```
124 %{
125 label: << city_name, territory - country name >>,
126 value: << city_id (UUID) >>
127 ```
128
129 ## Examples
130
131 iex> city_option_as_html_select(UUID)
132 %{label: "...", value: "UUID"}
133
134 iex> city_option_as_html_select(123)
135 %{label: "", value: ""}
136 """
137 @spec city_option_for_select(city_id :: Ecto.UUID.t()) :: %{
138 label: String.t(),
139 value: Ecto.UUID.t() | String.t()
140 }
141 def city_option_for_select(city_id) when is_bitstring(city_id) do
142 0 case Locations.get_city_territory_and_country!(city_id) do
143 nil ->
144 0 %{label: "", value: ""}
145
146 city ->
147 0 %{
148 0 label: "#{city.name}, #{city.level_1_division} - #{city.country_name}",
149 0 value: city.id
150 }
151 end
152 end
153
154 0 def city_option_as_html_select(_), do: %{label: "", value: ""}
155 end

lib/klepsidra/locations/continent.ex

100.0
2
115
0
Line Hits Source
0 defmodule Klepsidra.Locations.Continent do
1 @moduledoc """
2 Defines a schema for the `Continents` entity, listing continents of the world.
3
4 This is not meant to be a user-editable entity, imported on a periodic basis
5 from the [Geonames](https://geonames.org) project, specifically the
6 `continent_codes.csv` file (itself generated from the description on
7 https://download.geonames.org/export/dump/), with column headers added.
8 """
9
10 use Ecto.Schema
11 import Ecto.Changeset
12
13 @primary_key false
14 @type t :: %__MODULE__{
15 continent_code: String.t(),
16 continent_name: String.t(),
17 geoname_id: integer()
18 }
19 104 schema "locations_continents" do
20 field(:continent_code, :string, primary_key: true)
21 field(:continent_name, :string)
22 field(:geoname_id, :integer)
23
24 timestamps()
25 end
26
27 @doc false
28 def changeset(continent, attrs) do
29 continent
30 |> cast(attrs, [:continent_code, :continent_name, :geoname_id])
31 |> unique_constraint(:continent_code)
32 11 |> validate_required([:continent_code, :continent_name, :geoname_id])
33 end
34 end

lib/klepsidra/locations/country.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.Locations.Country do
1 @moduledoc """
2 Defines a schema for the `Country` entity, listing the countries of the world.
3
4 This is not meant to be a user-editable entity, imported on a periodic basis
5 from the [Geonames](https://geonames.org) project, specifically the `countryInfo.txt`
6 file, all countries' information, with the file annotation headers stripped off
7 and column headers converted to lowercase, underscore-separated names.
8 """
9
10 use Ecto.Schema
11 import Ecto.Changeset
12
13 @primary_key false
14 @type t :: %__MODULE__{
15 iso_country_code: String.t(),
16 iso_3_country_code: String.t(),
17 iso_numeric_country_code: integer(),
18 fips_country_code: String.t(),
19 country_name: String.t(),
20 capital: String.t(),
21 area: float(),
22 population: integer(),
23 continent_code: String.t(),
24 tld: String.t(),
25 currency_code: String.t(),
26 currency_name: String.t(),
27 phone: String.t(),
28 postal_code_format: String.t(),
29 postal_code_regex: String.t(),
30 languages: String.t(),
31 geoname_id: integer(),
32 neighbours: String.t(),
33 equivalent_fips_code: String.t()
34 }
35 0 schema "locations_countries" do
36 field(:iso_country_code, :string, primary_key: true)
37 field(:iso_3_country_code, :string)
38 field(:iso_numeric_country_code, :integer)
39 field(:fips_country_code, :string)
40 field(:country_name, :string)
41 field(:capital, :string)
42 field(:area, :float)
43 field(:population, :integer)
44 field(:continent_code, :string)
45 field(:tld, :string)
46 field(:currency_code, :string)
47 field(:currency_name, :string)
48 field(:phone, :string)
49 field(:postal_code_format, :string)
50 field(:postal_code_regex, :string)
51 field(:languages, :string)
52 field(:geoname_id, :integer)
53 field(:neighbours, :string)
54 field(:equivalent_fips_code, :string)
55
56 timestamps()
57 end
58
59 @doc false
60 def changeset(country, attrs) do
61 country
62 |> cast(attrs, [
63 :iso_country_code,
64 :iso_3_country_code,
65 :iso_numeric_country_code,
66 :fips_country_code,
67 :country_name,
68 :capital,
69 :area,
70 :population,
71 :continent_code,
72 :tld,
73 :currency_code,
74 :currency_name,
75 :phone,
76 :postal_code_format,
77 :postal_code_regex,
78 :languages,
79 :geoname_id,
80 :neighbours,
81 :equivalent_fips_code
82 ])
83 |> unique_constraint([
84 :iso_country_code,
85 :iso_3_country_code,
86 :iso_numeric_country_code,
87 :geoname_id
88 ])
89 |> foreign_key_constraint(:continent_code)
90 0 |> validate_required([
91 :iso_country_code,
92 :iso_3_country_code,
93 :iso_numeric_country_code,
94 :country_name,
95 :area,
96 :population,
97 :continent_code,
98 :geoname_id
99 ])
100 end
101 end

lib/klepsidra/locations/feature_class.ex

100.0
2
115
0
Line Hits Source
0 defmodule Klepsidra.Locations.FeatureClass do
1 @moduledoc """
2 Defines a schema for the `FeatureClass` entity, listing GeoNames'
3 feature classes, used for categorising locations around the world.
4 This data is used in their cities database.
5
6 This is not meant to be a user-editable entity, imported on a periodic basis
7 from the [Geonames](https://geonames.org) project, specifically the
8 `feature_classes.csv` file (itself generated from the description on
9 https://download.geonames.org/export/dump/), with column headers added.
10 """
11
12 use Ecto.Schema
13 import Ecto.Changeset
14
15 @primary_key false
16 @type t :: %__MODULE__{
17 feature_class: String.t(),
18 description: String.t()
19 }
20 104 schema "locations_feature_classes" do
21 field(:feature_class, :string, primary_key: true)
22 field(:description, :string)
23
24 timestamps()
25 end
26
27 @doc false
28 def changeset(feature_class, attrs) do
29 feature_class
30 |> cast(attrs, [:feature_class, :description])
31 |> unique_constraint(:feature_class)
32 11 |> validate_required([:feature_class])
33 end
34 end

lib/klepsidra/locations/feature_code.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.Locations.FeatureCode do
1 @moduledoc """
2 Defines a schema for the `FeatureCode` entity, listing GeoNames'
3 feature classes and codes, categorising locations around the world. This
4 data is used in their cities database.
5
6 This is not meant to be a user-editable entity, imported on a periodic basis
7 from the [Geonames](https://geonames.org) project, specifically the `featureCodes.txt`
8 file, with column headers converted to lowercase, underscore-separated names.
9 """
10
11 use Ecto.Schema
12 import Ecto.Changeset
13
14 @primary_key false
15 @type t :: %__MODULE__{
16 feature_code: String.t(),
17 feature_class: String.t(),
18 order: integer(),
19 description: String.t(),
20 note: String.t()
21 }
22 0 schema "locations_feature_codes" do
23 field(:feature_code, :string, primary_key: true)
24 field(:feature_class, :string)
25 field(:order, :integer)
26 field(:description, :string)
27 field(:note, :string)
28
29 timestamps()
30 end
31
32 @doc false
33 def changeset(feature_code, attrs) do
34 feature_code
35 |> cast(attrs, [:feature_code, :feature_class, :description, :note, :order])
36 |> unique_constraint(:feature_code)
37 |> foreign_key_constraint(:feature_class)
38 0 |> validate_required([:feature_code, :feature_class, :order, :description])
39 end
40 end

lib/klepsidra/mailer.ex

0.0
0
0
0
Line Hits Source
0 defmodule Klepsidra.Mailer do
1 @moduledoc false
2
3 use Swoosh.Mailer, otp_app: :klepsidra
4 end

lib/klepsidra/projects.ex

57.1
14
56
6
Line Hits Source
0 defmodule Klepsidra.Projects do
1 @moduledoc """
2 The Projects context.
3 """
4
5 import Ecto.Query, warn: false
6 alias Klepsidra.Repo
7
8 alias Klepsidra.Projects.Project
9
10 @doc """
11 Returns the list of projects.
12
13 ## Examples
14
15 iex> list_projects()
16 [%Project{}, ...]
17
18 """
19 def list_projects do
20 9 Project |> order_by(asc: fragment("name COLLATE NOCASE")) |> Repo.all()
21 end
22
23 @doc """
24 Returns the list of active projects.
25
26 ## Examples
27
28 iex> list_active_projects()
29 [%Project{}, ...]
30
31 """
32 def list_active_projects do
33 Project
34 |> where(active: true)
35 2 |> order_by(asc: fragment("name COLLATE NOCASE"))
36 2 |> Repo.all()
37 end
38
39 @doc """
40 Gets a single project.
41
42 Raises `Ecto.NoResultsError` if the Project does not exist.
43
44 ## Examples
45
46 iex> get_project!(123)
47 %Project{}
48
49 iex> get_project!(456)
50 ** (Ecto.NoResultsError)
51
52 """
53 15 def get_project!(id), do: Repo.get!(Project, id)
54
55 @doc """
56 Creates a project.
57
58 ## Examples
59
60 iex> create_project(%{field: value})
61 {:ok, %Project{}}
62
63 iex> create_project(%{field: bad_value})
64 {:error, %Ecto.Changeset{}}
65
66 """
67 def create_project(attrs \\ %{}) do
68 %Project{}
69 |> Project.changeset(attrs)
70 15 |> Repo.insert()
71 end
72
73 @doc """
74 Updates a project.
75
76 ## Examples
77
78 iex> update_project(project, %{field: new_value})
79 {:ok, %Project{}}
80
81 iex> update_project(project, %{field: bad_value})
82 {:error, %Ecto.Changeset{}}
83
84 """
85 def update_project(%Project{} = project, attrs) do
86 project
87 |> Project.changeset(attrs)
88 4 |> Repo.update()
89 end
90
91 @doc """
92 Deletes a project.
93
94 ## Examples
95
96 iex> delete_project(project)
97 {:ok, %Project{}}
98
99 iex> delete_project(project)
100 {:error, %Ecto.Changeset{}}
101
102 """
103 def delete_project(%Project{} = project) do
104 2 Repo.delete(project)
105 end
106
107 @doc """
108 Returns an `%Ecto.Changeset{}` for tracking project changes.
109
110 ## Examples
111
112 iex> change_project(project)
113 %Ecto.Changeset{data: %Project{}}
114
115 """
116 def change_project(%Project{} = project, attrs \\ %{}) do
117 7 Project.changeset(project, attrs)
118 end
119
120 alias Klepsidra.Projects.Note
121
122 @doc """
123 Returns the list of project_notes.
124
125 ## Examples
126
127 iex> list_project_notes()
128 [%Note{}, ...]
129
130 """
131 def list_project_notes do
132 0 Repo.all(Note)
133 end
134
135 @doc """
136 Gets a single note.
137
138 Raises `Ecto.NoResultsError` if the Note does not exist.
139
140 ## Examples
141
142 iex> get_note!(123)
143 %Note{}
144
145 iex> get_note!(456)
146 ** (Ecto.NoResultsError)
147
148 """
149 0 def get_note!(id), do: Repo.get!(Note, id)
150
151 @doc """
152 Creates a note.
153
154 ## Examples
155
156 iex> create_note(%{field: value})
157 {:ok, %Note{}}
158
159 iex> create_note(%{field: bad_value})
160 {:error, %Ecto.Changeset{}}
161
162 """
163 def create_note(attrs \\ %{}) do
164 %Note{}
165 |> Note.changeset(attrs)
166 0 |> Repo.insert()
167 end
168
169 @doc """
170 Updates a note.
171
172 ## Examples
173
174 iex> update_note(note, %{field: new_value})
175 {:ok, %Note{}}
176
177 iex> update_note(note, %{field: bad_value})
178 {:error, %Ecto.Changeset{}}
179
180 """
181 def update_note(%Note{} = note, attrs) do
182 note
183 |> Note.changeset(attrs)
184 0 |> Repo.update()
185 end
186
187 @doc """
188 Deletes a note.
189
190 ## Examples
191
192 iex> delete_note(note)
193 {:ok, %Note{}}
194
195 iex> delete_note(note)
196 {:error, %Ecto.Changeset{}}
197
198 """
199 def delete_note(%Note{} = note) do
200 0 Repo.delete(note)
201 end
202
203 @doc """
204 Returns an `%Ecto.Changeset{}` for tracking note changes.
205
206 ## Examples
207
208 iex> change_note(note)
209 %Ecto.Changeset{data: %Note{}}
210
211 """
212 def change_note(%Note{} = note, attrs \\ %{}) do
213 0 Note.changeset(note, attrs)
214 end
215 end

lib/klepsidra/projects/note.ex

0.0
2
0
2
Line Hits Source
0 defmodule Klepsidra.Projects.Note do
1 @moduledoc """
2 Defines the schema for the project `notes` entity, annotations
3 of ongoing management of projects.
4 """
5
6 use Ecto.Schema
7 import Ecto.Changeset
8
9 @primary_key {:id, Ecto.UUID, autogenerate: true}
10 @foreign_key_type Ecto.UUID
11
12 @type t :: %__MODULE__{
13 note: String.t(),
14 project_id: binary()
15 }
16 0 schema "project_notes" do
17 field :note, :string
18 belongs_to :project, Project, type: Ecto.UUID
19
20 timestamps()
21 end
22
23 @doc false
24 def changeset(note, attrs) do
25 note
26 |> cast(attrs, [:note, :project_id])
27 |> validate_required([:note], message: "The message can't be empty")
28 0 |> assoc_constraint(:project)
29 end
30 end

lib/klepsidra/projects/project.ex

50.0
6
334
3
Line Hits Source
0 defmodule Klepsidra.Projects.Project do
1 @moduledoc """
2 Defines a schema for the `Projects` entity, used to label long-running projects.
3
4 Projects can be initiated by both customers, as well as in response to supplier
5 requirements, and can be linked to a `BusinessPartner` entity.
6
7 Timers can also belong to projects, timing disparate activities as part of a
8 long-running project.
9 """
10
11 use Ecto.Schema
12 import Ecto.Changeset
13 alias Klepsidra.BusinessPartners.BusinessPartner
14
15 @primary_key {:id, Ecto.UUID, autogenerate: true}
16 @foreign_key_type Ecto.UUID
17
18 @type t :: %__MODULE__{
19 name: String.t(),
20 description: String.t(),
21 business_partner_id: binary(),
22 active: boolean()
23 }
24 306 schema "projects" do
25 field :name, :string
26 field :description, :string
27 field :active, :boolean, default: true
28
29 belongs_to :business_partner, BusinessPartner, type: Ecto.UUID
30
31 many_to_many(:tags, Klepsidra.Categorisation.Tag,
32 join_through: "project_tags",
33 on_replace: :delete,
34 preload_order: [asc: :name]
35 )
36
37 timestamps()
38 end
39
40 @doc false
41 def changeset(project, attrs) do
42 project
43 |> cast(attrs, [:name, :description, :active])
44 |> validate_required([:name], message: "Enter the project name")
45 26 |> unique_constraint(:name,
46 name: :projects_name_index,
47 message: "A project with this name already exists"
48 )
49 end
50
51 @doc """
52 Used across live components to populate select options with projects.
53 """
54 @spec populate_projects_list() :: [Klepsidra.Projects.Project.t(), ...]
55 2 def populate_projects_list() do
56 [
57 {"", ""}
58 | Klepsidra.Projects.list_active_projects()
59 0 |> Enum.map(fn project -> {project.name, project.id} end)
60 ]
61 end
62
63 0 def projects_list() do
64 [
65 {"", ""}
66 | Klepsidra.Projects.list_active_projects()
67 0 |> Enum.map(fn project -> {to_string(project.name), to_string(project.id)} end)
68 ]
69 end
70 end

lib/klepsidra/repo.ex

0.0
0
0
0
Line Hits Source
0 defmodule Klepsidra.Repo do
1 @moduledoc false
2
3 use Ecto.Repo,
4 otp_app: :klepsidra,
5 adapter: Ecto.Adapters.SQLite3
6 end

lib/klepsidra/time_tracking.ex

23.7
118
178
90
Line Hits Source
0 defmodule Klepsidra.TimeTracking do
1 @moduledoc """
2 The TimeTracking context.
3 """
4
5 import Ecto.Query, warn: false
6 alias Klepsidra.Repo
7 alias Klepsidra.TimeTracking.ActivityType
8 alias Klepsidra.TimeTracking.Note
9 alias Klepsidra.TimeTracking.Timer
10 alias Klepsidra.Math
11
12 @typedoc """
13 The `timer_record.t()` type is a list of the fields and data types returned in
14 timer record queries.
15 """
16 @type timer_record :: %{
17 id: binary(),
18 start_stamp: binary(),
19 end_stamp: binary(),
20 duration: integer(),
21 duration_time_unit: binary(),
22 project_id: nil | binary(),
23 project_name: binary(),
24 business_partner_id: nil | binary(),
25 business_partner_name: binary(),
26 billable: boolean(),
27 billing_duration: integer(),
28 billing_duration_time_unit: binary(),
29 billing_rate: Decimal.t(),
30 activity_type_id: nil | binary(),
31 activity_type: binary(),
32 description: binary(),
33 inserted_at: NaiveDateTime.t(),
34 updated_at: NaiveDateTime.t(),
35 modified: integer()
36 }
37 @typedoc """
38 The `duration.t()` type is a map containing a time duration in several formats:
39
40 * The duration as a `Cldr.Unit.t()` type, denominated in the base time increment, `:second`
41 * The duration in hours, a basic major unit of the day
42 * A human-readable string representing the time converted to days, hours and minutes,
43 or nil
44 """
45 @type duration :: %{
46 base_unit_duration: Cldr.Unit.t(),
47 duration_in_hours: bitstring(),
48 human_readable_duration: bitstring() | nil
49 }
50 @typedoc """
51 When filtering timer queries, a `timer_filter.t()` filter structure will be passed
52 with criteria to filter by. The type specifies those fields and their types.
53 """
54 @type timer_filter :: %{
55 from: bitstring(),
56 to: bitstring(),
57 project_id: bitstring(),
58 business_partner_id: bitstring(),
59 activity_type_id: bitstring(),
60 billable: bitstring(),
61 modified: binary() | integer()
62 }
63
64 @doc """
65 Returns the list of timers.
66
67 ## Examples
68
69 iex> list_timers()
70 [%Timer{}, ...]
71
72 """
73 def list_timers do
74 1 Timer |> order_by(desc: :start_stamp) |> Repo.all()
75 end
76
77 @spec list_timers(filter :: map()) :: [map(), ...]
78 @doc """
79 Returns a list of timers, filtered by criteria in the `filter`
80 parameter.
81
82 ## Examples
83
84 iex> list_timers(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""})
85 [%{...}]
86 """
87 def list_timers(%{modified: modified} = filter) when is_map(filter) do
88 list_timers_query(filter)
89 |> filter_by_modification_status(%{modified: modified})
90 0 |> Repo.all()
91 end
92
93 @doc """
94
95 ## Examples
96
97 iex> list_timers_with_statistics(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""})
98 %{meta: %{
99 aggregate_duration: %{
100 duration_in_hours: "4.2 hours",
101 base_unit_duration: Cldr.Unit.new!(:second, 15120),
102 human_readable_duration: nil},
103 aggregate_billing_duration: %{
104 duration_in_hours: "5.3 hours",
105 base_unit_duration: Cldr.Unit.new!(:second, 18900),
106 human_readable_duration: nil}},
107 timer_list: [%{...}, ...]}
108 """
109 @spec list_timers_with_statistics(filter :: timer_filter()) :: %{
110 timer_list: any(),
111 meta: %{
112 timer_count: any(),
113 aggregate_duration: any(),
114 average_timer_duration: any(),
115 aggregate_billing_duration: any()
116 }
117 }
118 def list_timers_with_statistics(filter) when is_map(filter) do
119 0 timer_count = list_timers_count(filter)
120 0 timer_duration = list_timers_aggregate_duration(filter)
121
122 0 average_timer_duration =
123 0 Math.arithmetic_mean(timer_duration.base_unit_duration, timer_count)
124 |> Timer.format_aggregate_duration_for_project()
125
126 0 %{
127 timer_list: list_timers(filter),
128 meta: %{
129 timer_count: timer_count,
130 aggregate_duration: timer_duration,
131 average_timer_duration: average_timer_duration,
132 aggregate_billing_duration: list_timers_aggregate_billing_duration(filter)
133 }
134 }
135 end
136
137 @doc """
138
139 ## Examples
140
141 iex> list_timers_count(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""})
142 9
143 """
144 @spec list_timers_count(filter :: map()) :: non_neg_integer()
145 def list_timers_count(%{modified: modified} = filter) when is_map(filter) do
146 list_timers_query(filter)
147 |> filter_by_modification_status(%{modified: modified})
148 0 |> select([at], count(at.id))
149 0 |> Repo.one()
150 end
151
152 @doc """
153
154 ## Examples
155
156 iex> list_timers_aggregate_duration(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""})
157 %{
158 duration_in_hours: "4.2 hours",
159 base_unit_duration: Cldr.Unit.new!(:second, 15120),
160 human_readable_duration: nil
161 }
162 """
163 @spec list_timers_aggregate_duration(filter :: map()) :: duration()
164 def list_timers_aggregate_duration(filter) when is_map(filter) do
165 list_timers_query(filter)
166 |> select([at], {sum(at.duration), at.duration_time_unit})
167 0 |> group_by([at], at.duration_time_unit)
168 |> Repo.all()
169 0 |> Timer.calculate_aggregate_duration_for_timers()
170 end
171
172 @doc """
173
174 ## Examples
175
176 iex> list_timers_aggregate_billing_duration(%{from: "", to: "", project_id: "", business_partner_id: "90bc20d3-be65-46ea-a579-453d6ae3d378", activity_type_id: "", billable: "", modified: ""})
177 %{
178 duration_in_hours: "5.3 hours",
179 base_unit_duration: Cldr.Unit.new!(:second, 18900),
180 human_readable_duration: nil
181 }
182 """
183 @spec list_timers_aggregate_billing_duration(filter :: map()) :: %{
184 base_unit_duration: Cldr.Unit.t(),
185 duration_in_hours: bitstring(),
186 human_readable_duration: bitstring() | nil
187 }
188 def list_timers_aggregate_billing_duration(filter) when is_map(filter) do
189 list_timers_query(filter)
190 |> select([at], {sum(at.billing_duration), at.billing_duration_time_unit})
191 0 |> group_by([at], at.billing_duration_time_unit)
192 |> Repo.all()
193 0 |> Timer.calculate_aggregate_duration_for_timers()
194 end
195
196 defp list_timers_query(filter) when is_map(filter) do
197 %{
198 from: from,
199 to: to,
200 project_id: project_id,
201 business_partner_id: business_partner_id,
202 activity_type_id: activity_type_id,
203 billable: billable
204 0 } =
205 filter
206
207 0 order_by = [asc: :inserted_at]
208
209 0 query =
210 0 from(
211 at in Timer,
212 left_join: p in assoc(at, :project),
213 left_join: bp in assoc(at, :business_partner),
214 left_join: act in assoc(at, :activity_type),
215 where: not is_nil(at.end_stamp),
216 order_by: ^order_by,
217 select: %{
218 id: at.id,
219 start_stamp: at.start_stamp,
220 end_stamp: at.end_stamp,
221 duration: at.duration,
222 duration_time_unit: at.duration_time_unit,
223 project_id: p.id,
224 project_name: p.name |> coalesce(""),
225 business_partner_id: at.business_partner_id,
226 business_partner_name: bp.name |> coalesce(""),
227 billable: at.billable,
228 billing_duration: at.billing_duration,
229 billing_duration_time_unit: at.billing_duration_time_unit,
230 billing_rate: at.billing_rate,
231 activity_type_id: act.id,
232 activity_type: act.name |> coalesce(""),
233 description: at.description |> coalesce(""),
234 inserted_at: at.inserted_at,
235 updated_at: at.updated_at,
236 modified: fragment("iif(? != ?, true, false)", at.inserted_at, at.updated_at)
237 }
238 )
239
240 0 timer_subquery =
241 query
242 |> filter_by_date(%{from: from, to: to})
243 |> filter_by_project_id(%{project_id: project_id})
244 |> filter_by_business_partner_id(%{business_partner_id: business_partner_id})
245 |> filter_by_activity_type_id(%{activity_type_id: activity_type_id})
246 |> filter_by_billable(%{billable: billable})
247
248 0 from(at in subquery(timer_subquery))
249 end
250
251 0 defp filter_by_date(query, %{from: "", to: ""}), do: query
252
253 defp filter_by_date(query, %{from: from, to: ""}) do
254 0 where(query, [at], at.start_stamp >= ^from)
255 end
256
257 defp filter_by_date(query, %{from: "", to: to}) do
258 0 where(query, [at], at.end_stamp <= ^to)
259 end
260
261 defp filter_by_date(query, %{from: from, to: to}) do
262 query
263 |> where([at], at.start_stamp >= ^from)
264 0 |> where([at], at.end_stamp <= ^to)
265 end
266
267 0 defp filter_by_project_id(query, %{project_id: ""}), do: query
268
269 defp filter_by_project_id(query, %{project_id: project_id}) do
270 query
271 0 |> where([at], at.project_id == ^project_id)
272 end
273
274 0 defp filter_by_business_partner_id(query, %{business_partner_id: ""}), do: query
275
276 defp filter_by_business_partner_id(query, %{business_partner_id: business_partner_id}) do
277 query
278 0 |> where([at], at.business_partner_id == ^business_partner_id)
279 end
280
281 0 defp filter_by_activity_type_id(query, %{activity_type_id: ""}), do: query
282
283 defp filter_by_activity_type_id(query, %{activity_type_id: activity_type_id}) do
284 query
285 0 |> where([at], at.activity_type_id == ^activity_type_id)
286 end
287
288 0 defp filter_by_billable(query, %{billable: ""}), do: query
289
290 defp filter_by_billable(query, %{billable: billable}) do
291 query
292 0 |> where([at], at.billable == ^billable)
293 end
294
295 0 defp filter_by_modification_status(query, %{modified: ""}), do: query
296
297 defp filter_by_modification_status(query, %{modified: modified}) when is_bitstring(modified) do
298 query
299 0 |> where([at], at.modified == ^String.to_integer(modified))
300 end
301
302 defp filter_by_modification_status(query, %{modified: modified}) when is_integer(modified) do
303 query
304 0 |> where([at], at.modified == ^modified)
305 end
306
307 @doc """
308 Returns the list of timers, along with the associated tags.
309
310 ## Examples
311
312 iex> list_timers_with_tags()
313 [%Timer{}, ...]
314
315 """
316 def list_timers_with_tags do
317 0 query =
318 from(
319 at in Timer,
320 order_by: [desc: :start_stamp],
321 preload: :tags
322 )
323
324 0 Repo.all(query)
325 end
326
327 @doc """
328 Returns the list of timers, with `business_partner` association preloaded.
329
330 ## Examples
331
332 iex> list_timers()
333 [%Timer{}, ...]
334
335 """
336 def list_timers_with_customers do
337 8 query =
338 8 from(
339 at in Timer,
340 left_join: bp in assoc(at, :business_partner),
341 left_join: p in assoc(at, :project),
342 order_by: [desc: at.inserted_at, asc: at.id],
343 select: %{
344 id: at.id,
345 start_stamp: at.start_stamp,
346 end_stamp: at.end_stamp,
347 duration: at.duration,
348 duration_time_unit: at.duration_time_unit,
349 billing_duration: at.billing_duration,
350 billing_duration_time_unit: at.billing_duration_time_unit,
351 description: at.description |> coalesce(""),
352 project_name: p.name |> coalesce(""),
353 business_partner_id: at.business_partner_id,
354 business_partner_name: bp.name |> coalesce(""),
355 inserted_at: at.inserted_at
356 }
357 )
358
359 query
360 |> Repo.all()
361 8 |> Enum.map(fn rec ->
362 8 Map.merge(rec, %{
363 start_stamp:
364 8 Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.start_stamp)),
365 end_stamp:
366 8 if(rec.end_stamp,
367 8 do: Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.end_stamp))
368 ),
369 summary:
370 8 rec.description
371 |> markdown_to_html()
372 |> Phoenix.HTML.raw(),
373 formatted_start_date: nil,
374 formatted_duration:
375 8 {rec.duration, rec.duration_time_unit}
376 |> Timer.convert_duration_to_base_time_unit()
377 |> Klepsidra.TimeTracking.Timer.format_human_readable_duration()
378 })
379 end)
380 end
381
382 @doc """
383 Gets a single timer.
384
385 Raises `Ecto.NoResultsError` if the Timer does not exist.
386
387 ## Examples
388
389 iex> get_timer!(123)
390 %Timer{}
391
392 iex> get_timer!(456)
393 ** (Ecto.NoResultsError)
394
395 """
396 9 def get_timer!(id), do: Repo.get!(Timer, id)
397
398 @doc """
399 Gets a single timer, with its `business_partner` association preloaded.
400
401 Raises `Ecto.NoResultsError` if the Timer does not exist.
402
403 ## Examples
404
405 iex> get_timer!(123)
406 %Timer{}
407
408 iex> get_timer!(456)
409 ** (Ecto.NoResultsError)
410 """
411 def get_formatted_timer_record!(id) do
412 0 query =
413 0 from(at in Timer,
414 where: at.id == ^id,
415 left_join: p in assoc(at, :project),
416 left_join: bp in assoc(at, :business_partner),
417 left_join: act in assoc(at, :activity_type),
418 select: %{
419 id: at.id,
420 start_stamp: at.start_stamp,
421 end_stamp: at.end_stamp,
422 duration: at.duration,
423 duration_time_unit: at.duration_time_unit,
424 project_id: p.id,
425 project_name: p.name |> coalesce(""),
426 business_partner_id: at.business_partner_id,
427 business_partner_name: bp.name |> coalesce(""),
428 billable: at.billable,
429 billing_duration: at.billing_duration,
430 billing_duration_time_unit: at.billing_duration_time_unit,
431 billing_rate: at.billing_rate,
432 activity_type_id: act.id,
433 activity_type: act.name |> coalesce(""),
434 description: at.description |> coalesce(""),
435 inserted_at: at.inserted_at
436 }
437 )
438
439 query
440 |> Repo.all()
441 |> Enum.map(fn rec ->
442 0 Map.merge(rec, %{
443 start_stamp:
444 0 Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.start_stamp)),
445 end_stamp:
446 0 if(rec.end_stamp,
447 0 do: Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.end_stamp))
448 ),
449 summary:
450 0 rec.description
451 |> markdown_to_html()
452 |> Phoenix.HTML.raw(),
453 formatted_start_date: "",
454 formatted_duration:
455 0 {rec.duration, rec.duration_time_unit}
456 |> Timer.convert_duration_to_base_time_unit()
457 |> Klepsidra.TimeTracking.Timer.format_human_readable_duration()
458 })
459 end)
460 0 |> List.first()
461 end
462
463 @doc """
464 Gets a list of closed timers started on the specified date.
465
466 A closed timer is one which has an end datetime stamp recorded, as well as
467 a starting one.
468 """
469 @spec get_closed_timers_for_date(NaiveDateTime.t()) ::
470 [Klepsidra.TimeTracking.Timer.t(), ...] | []
471 def get_closed_timers_for_date(date) when is_struct(date, NaiveDateTime) do
472 0 start_of_day = NaiveDateTime.beginning_of_day(date)
473 0 end_of_day = NaiveDateTime.add(start_of_day, 24, :hour)
474
475 0 query =
476 0 from(
477 at in Timer,
478 left_join: bp in assoc(at, :business_partner),
479 left_join: p in assoc(at, :project),
480 where:
481 at.start_stamp <= type(^end_of_day, :naive_datetime) and
482 at.end_stamp >= type(^start_of_day, :naive_datetime) and
483 not is_nil(at.end_stamp),
484 order_by: [desc: at.inserted_at, asc: at.id],
485 select: %{
486 id: at.id,
487 start_stamp: at.start_stamp,
488 end_stamp: at.end_stamp,
489 duration: at.duration,
490 duration_time_unit: at.duration_time_unit,
491 description: at.description |> coalesce(""),
492 project_name: p.name |> coalesce(""),
493 business_partner_id: at.business_partner_id,
494 business_partner_name: bp.name |> coalesce(""),
495 inserted_at: at.inserted_at
496 }
497 )
498
499 query
500 |> Repo.all()
501 0 |> Enum.map(fn rec ->
502 0 Map.merge(rec, %{
503 start_stamp:
504 0 Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.start_stamp)),
505 0 end_stamp: Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.end_stamp)),
506 summary:
507 0 rec.description
508 |> markdown_to_html()
509 |> Phoenix.HTML.raw(),
510 formatted_start_date:
511 0 if(
512 0 NaiveDateTime.compare(Timer.parse_html_datetime!(rec.start_stamp), start_of_day) ==
513 :lt,
514 do: "Started yesterday",
515 else: ""
516 ),
517 formatted_duration:
518 0 {rec.duration, rec.duration_time_unit}
519 |> Timer.convert_duration_to_base_time_unit()
520 |> Klepsidra.TimeTracking.Timer.format_human_readable_duration()
521 })
522 end)
523 end
524
525 @doc """
526 """
527 def truncate(text, opts) do
528 0 max_length = opts[:max_length] || 59
529 0 omission = opts[:omission] || "..."
530
531 0 cond do
532 not String.valid?(text) ->
533 0 text
534
535 0 String.length(text) < max_length ->
536 0 text
537
538 0 true ->
539 0 length_with_omission = max_length - String.length(omission)
540
541 0 "#{String.slice(text, 0, length_with_omission)}#{omission}"
542 end
543 end
544
545 @doc """
546 """
547 def markdown_to_html(markdown, _options \\ []) do
548 markdown
549 |> Earmark.as_html!(
550 compact_output: true,
551 code_class_prefix: "lang-",
552 smartypants: true
553 )
554 8 |> HtmlSanitizeEx.html5()
555 end
556
557 @doc """
558 Gets a count of closed timers started on the specified date.
559
560 A closed timer is one which has an end datetime stamp recorded, as well as
561 a starting one.
562 """
563 @spec get_closed_timer_count_for_date(NaiveDateTime.t()) :: integer()
564 def get_closed_timer_count_for_date(date) when is_struct(date, NaiveDateTime) do
565 0 start_of_day = NaiveDateTime.beginning_of_day(date)
566 0 end_of_day = NaiveDateTime.add(start_of_day, 24, :hour)
567
568 0 query =
569 from(
570 at in "timers",
571 select: count(at.id),
572 where:
573 at.start_stamp <= type(^end_of_day, :naive_datetime) and
574 at.end_stamp >= type(^start_of_day, :naive_datetime) and
575 not is_nil(at.end_stamp)
576 )
577
578 0 Repo.one(query)
579 end
580
581 @doc """
582 Gets a count of all open timers.
583
584 An open timer is one without an end datetime stamp recorded, as well as
585 a starting one.
586 """
587 @spec get_open_timer_count() :: integer()
588 def get_open_timer_count() do
589 0 query =
590 from(
591 at in "timers",
592 select: count(at.id),
593 where:
594 not is_nil(at.start_stamp) and
595 is_nil(at.end_stamp)
596 )
597
598 0 Repo.one(query)
599 end
600
601 @doc """
602 Gets a sum of timer durations for the specified date, by time unit.
603
604 A closed timer is one which has an end datetime stamp recorded, as well as
605 a starting one.
606 """
607 @spec get_closed_timer_durations_for_date(NaiveDateTime.t()) ::
608 [{integer, bitstring()}, ...] | []
609 def get_closed_timer_durations_for_date(date) when is_struct(date, NaiveDateTime) do
610 0 start_of_day = NaiveDateTime.beginning_of_day(date)
611 0 end_of_day = NaiveDateTime.add(start_of_day, 24, :hour)
612
613 0 query =
614 from(
615 at in "timers",
616 select: {sum(at.duration), at.duration_time_unit},
617 group_by: at.duration_time_unit,
618 where:
619 at.start_stamp <= type(^end_of_day, :naive_datetime) and
620 at.end_stamp >= type(^start_of_day, :naive_datetime) and
621 not is_nil(at.end_stamp)
622 )
623
624 0 Repo.all(query)
625 end
626
627 @doc """
628 Gets a sum of timer durations for the specified project, by time unit.
629
630 A closed timer is one which has an end datetime stamp recorded, as well as
631 a starting one.
632 """
633 @spec get_closed_timer_durations_for_project(bitstring()) ::
634 [{integer, bitstring()}, ...] | []
635 def get_closed_timer_durations_for_project(project_id)
636 when is_bitstring(project_id) do
637 6 query =
638 from(
639 at in "timers",
640 select: {sum(at.duration), at.duration_time_unit},
641 group_by: at.duration_time_unit,
642 where:
643 not is_nil(at.start_stamp) and
644 not is_nil(at.end_stamp) and
645 at.project_id == ^project_id
646 )
647
648 6 Repo.all(query)
649 end
650
651 @doc """
652 Gets a list of all open timers.
653
654 A timer is considered open if it has no `end_stamp`.
655 """
656 @spec get_all_open_timers() :: [Klepsidra.TimeTracking.Timer.t(), ...] | []
657 def get_all_open_timers() do
658 0 query =
659 0 from(
660 at in Timer,
661 left_join: bp in assoc(at, :business_partner),
662 left_join: p in assoc(at, :project),
663 select: %{
664 id: at.id,
665 start_stamp: at.start_stamp,
666 end_stamp: at.end_stamp,
667 duration: at.duration,
668 duration_time_unit: at.duration_time_unit,
669 description: at.description |> coalesce(""),
670 project_name: p.name |> coalesce(""),
671 business_partner_id: at.business_partner_id,
672 business_partner_name: bp.name |> coalesce(""),
673 inserted_at: at.inserted_at
674 },
675 where:
676 not is_nil(at.start_stamp) and
677 is_nil(at.end_stamp),
678 order_by: [desc: at.start_stamp, desc: at.inserted_at]
679 )
680
681 query
682 |> Repo.all()
683 0 |> Enum.map(fn rec ->
684 0 Map.merge(rec, %{
685 start_stamp:
686 0 Timer.format_human_readable_time!(Timer.parse_html_datetime!(rec.start_stamp)),
687 end_stamp: nil,
688 formatted_start_date:
689 Timex.from_now(
690 0 Timer.parse_html_datetime!(rec.start_stamp),
691 NaiveDateTime.local_now()
692 ),
693 summary:
694 0 rec.description
695 0 |> to_string()
696 |> markdown_to_html()
697 |> Phoenix.HTML.raw()
698 })
699 end)
700 end
701
702 @doc """
703 Creates a timer.
704
705 ## Examples
706
707 iex> create_timer(%{field: value})
708 {:ok, %Timer{}}
709
710 iex> create_timer(%{field: bad_value})
711 {:error, %Ecto.Changeset{}}
712
713 """
714 def create_timer(attrs \\ %{}) do
715 %Timer{}
716 |> Timer.changeset(attrs)
717 13 |> Repo.insert()
718 end
719
720 @doc """
721 Updates a timer.
722
723 ## Examples
724
725 iex> update_timer(timer, %{field: new_value})
726 {:ok, %Timer{}}
727
728 iex> update_timer(timer, %{field: bad_value})
729 {:error, %Ecto.Changeset{}}
730
731 """
732 def update_timer(%Timer{} = timer, attrs) do
733 timer
734 |> Timer.changeset(attrs)
735 3 |> Repo.update()
736 end
737
738 @doc """
739 Deletes a timer.
740
741 ## Examples
742
743 iex> delete_timer(timer)
744 {:ok, %Timer{}}
745
746 iex> delete_timer(timer)
747 {:error, %Ecto.Changeset{}}
748
749 """
750 def delete_timer(%Timer{} = timer) do
751 2 Repo.delete(timer)
752 end
753
754 @doc """
755 Returns an `%Ecto.Changeset{}` for tracking timer changes.
756
757 ## Examples
758
759 iex> change_timer(timer)
760 %Ecto.Changeset{data: %Timer{}}
761
762 """
763 def change_timer(%Timer{} = timer, attrs \\ %{}) do
764 3 Timer.changeset(timer, attrs)
765 end
766
767 @doc """
768 Returns the list of notes.
769
770 ## Examples
771
772 iex> list_notes()
773 [%Note{}, ...]
774
775 """
776 def list_notes do
777 0 Repo.all(Note)
778 end
779
780 @doc """
781 Returns a list of notes matching the given `filter`.
782
783 Example filter:
784
785 %{timer_id: 42}
786 """
787 0 def list_notes(filter) when is_map(filter) do
788 # from(Note)
789 # |> filter_notes_by_timer(filter)
790 # |> Repo.all()
791 end
792
793 @doc """
794 Gets a single note.
795
796 Raises `Ecto.NoResultsError` if the Note does not exist.
797
798 ## Examples
799
800 iex> get_note!(123)
801 %Note{}
802
803 iex> get_note!(456)
804 ** (Ecto.NoResultsError)
805
806 """
807 0 def get_note!(id), do: Repo.get!(Note, id)
808
809 @doc false
810 def get_note_by_timer_id!(timer_id) do
811 Note
812 |> where(timer_id: ^timer_id)
813 2 |> order_by(desc: :inserted_at)
814 2 |> Repo.all()
815 end
816
817 @doc """
818 Creates a note.
819
820 ## Examples
821
822 iex> create_note(%{field: value})
823 {:ok, %Note{}}
824
825 iex> create_note(%{field: bad_value})
826 {:error, %Ecto.Changeset{}}
827
828 """
829 def create_note(attrs \\ %{}) do
830 %Note{}
831 |> Note.changeset(attrs)
832 1 |> Repo.insert()
833 end
834
835 @doc """
836 Updates a note.
837
838 ## Examples
839
840 iex> update_note(note, %{field: new_value})
841 {:ok, %Note{}}
842
843 iex> update_note(note, %{field: bad_value})
844 {:error, %Ecto.Changeset{}}
845
846 """
847 def update_note(%Note{} = note, attrs) do
848 note
849 |> Note.changeset(attrs)
850 0 |> Repo.update()
851 end
852
853 @doc """
854 Deletes a note.
855
856 ## Examples
857
858 iex> delete_note(note)
859 {:ok, %Note{}}
860
861 iex> delete_note(note)
862 {:error, %Ecto.Changeset{}}
863
864 """
865 def delete_note(%Note{} = note) do
866 0 Repo.delete(note)
867 end
868
869 @doc """
870 Returns an `%Ecto.Changeset{}` for tracking note changes.
871
872 ## Examples
873
874 iex> change_note(note)
875 %Ecto.Changeset{data: %Note{}}
876
877 """
878 def change_note(%Note{} = note, attrs \\ %{}) do
879 2 Note.changeset(note, attrs)
880 end
881
882 @doc """
883 Returns the list of activity_types.
884
885 ## Examples
886
887 iex> list_activity_types()
888 [%ActivityType{}, ...]
889
890 """
891 def list_activity_types do
892 9 ActivityType |> order_by(asc: fragment("name COLLATE NOCASE")) |> Repo.all()
893 end
894
895 @doc """
896 Returns the list of active activity_types.
897
898 ## Examples
899
900 iex> list_active_activity_types()
901 [%ActivityType{}, ...]
902
903 """
904 def list_active_activity_types do
905 ActivityType
906 |> where(active: true)
907 0 |> order_by(asc: fragment("name COLLATE NOCASE"))
908 0 |> Repo.all()
909 end
910
911 @doc """
912 Gets a single activity_type.
913
914 Raises `Ecto.NoResultsError` if the Activity type does not exist.
915
916 ## Examples
917
918 iex> get_activity_type!(123)
919 %ActivityType{}
920
921 iex> get_activity_type!(456)
922 ** (Ecto.NoResultsError)
923
924 """
925 11 def get_activity_type!(id), do: Repo.get!(ActivityType, id)
926
927 @doc """
928 Creates a activity_type.
929
930 ## Examples
931
932 iex> create_activity_type(%{field: value})
933 {:ok, %ActivityType{}}
934
935 iex> create_activity_type(%{field: bad_value})
936 {:error, %Ecto.Changeset{}}
937
938 """
939 def create_activity_type(attrs \\ %{}) do
940 %ActivityType{}
941 |> ActivityType.changeset(attrs)
942 15 |> Repo.insert()
943 end
944
945 @doc """
946 Updates a activity_type.
947
948 ## Examples
949
950 iex> update_activity_type(activity_type, %{field: new_value})
951 {:ok, %ActivityType{}}
952
953 iex> update_activity_type(activity_type, %{field: bad_value})
954 {:error, %Ecto.Changeset{}}
955
956 """
957 def update_activity_type(%ActivityType{} = activity_type, attrs) do
958 activity_type
959 |> ActivityType.changeset(attrs)
960 4 |> Repo.update()
961 end
962
963 @doc """
964 Deletes an activity_type.
965
966 ## Examples
967
968 iex> delete_activity_type(activity_type)
969 {:ok, %ActivityType{}}
970
971 iex> delete_activity_type(activity_type)
972 {:error, %Ecto.Changeset{}}
973
974 """
975 def delete_activity_type(%ActivityType{} = activity_type) do
976 2 Repo.delete(activity_type)
977 end
978
979 @doc """
980 Returns an `%Ecto.Changeset{}` for tracking activity_type changes.
981
982 ## Examples
983
984 iex> change_activity_type(activity_type)
985 %Ecto.Changeset{data: %ActivityType{}}
986
987 """
988 def change_activity_type(%ActivityType{} = activity_type, attrs \\ %{}) do
989 7 ActivityType.changeset(activity_type, attrs)
990 end
991 end

lib/klepsidra/time_tracking/activity_type.ex

50.0
4
295
2
Line Hits Source
0 defmodule Klepsidra.TimeTracking.ActivityType do
1 @moduledoc """
2 Defines a schema for the `ActivityType` entity, used to set activity types on timers.
3
4 Activity types are a way to preload billing defaults, to help calculate
5 billing amounts at time of invoicing.
6 """
7
8 use Ecto.Schema
9 import Ecto.Changeset
10
11 @primary_key {:id, Ecto.UUID, autogenerate: true}
12 @foreign_key_type Ecto.UUID
13
14 @type t :: %__MODULE__{
15 name: String.t(),
16 billing_rate: number(),
17 active: boolean()
18 }
19 269 schema "activity_types" do
20 field :name, :string
21 field :billing_rate, :decimal
22 field :active, :boolean, default: true
23
24 timestamps()
25 end
26
27 @doc false
28 def changeset(activity_type, attrs) do
29 activity_type
30 |> cast(attrs, [:name, :billing_rate, :active])
31 |> validate_required([:name], message: "Enter an activity type name")
32 |> unique_constraint(:name, message: "An activity type with this name already exists")
33 |> validate_required([:billing_rate], message: "The hourly billing rate must be a number")
34 26 |> validate_number(:billing_rate,
35 greater_than_or_equal_to: 0,
36 message: "The billing rate must be zero or greater"
37 )
38 end
39
40 @doc """
41 Used across live components to populate select options with activity types.
42 """
43 @spec populate_activity_types_list() :: [Klepsidra.TimeTracking.ActivityType.t(), ...]
44 0 def populate_activity_types_list() do
45 [
46 {"", ""}
47 | Klepsidra.TimeTracking.list_active_activity_types()
48 0 |> Enum.map(fn type -> {type.name, type.id} end)
49 ]
50 end
51 end

lib/klepsidra/time_tracking/note.ex

100.0
2
39
0
Line Hits Source
0 defmodule Klepsidra.TimeTracking.Note do
1 @moduledoc """
2 Defines the data schema for the `Note` entity, annotations of timed activities.
3 """
4
5 use Ecto.Schema
6 import Ecto.Changeset
7
8 @primary_key {:id, Ecto.UUID, autogenerate: true}
9 @foreign_key_type Ecto.UUID
10
11 @type t :: %__MODULE__{
12 note: String.t(),
13 timer_id: binary()
14 }
15 36 schema "timer_notes" do
16 field :note, :string
17 belongs_to :timer, Klepsidra.TimeTracking.Timer, type: Ecto.UUID
18
19 timestamps()
20 end
21
22 @doc false
23 def changeset(note, attrs) do
24 note
25 |> cast(attrs, [:note, :timer_id])
26 |> validate_required([:note], message: "The message can't be empty")
27 3 |> assoc_constraint(:timer)
28 end
29 end

lib/klepsidra/time_tracking/time_units.ex

100.0
6
45
0
Line Hits Source
0 defmodule Klepsidra.TimeTracking.TimeUnits do
1 @moduledoc """
2 Provides handling and user interface presentation of time units.
3 """
4
5 alias Klepsidra.Cldr.Unit.Additional, as: AdditionalUnits
6
7 @locale :en
8 @style :narrow
9 @default_billing_increment :thirty_minute_increment
10
11 @doc """
12 Returns the default billing increment for use in option select controls'
13 value property.
14
15 The returned value is a string, to be immediately usable, without further
16 conversion. The default is stored, compiled, in the module attribute
17 `@default_billing_increment`, which will be supplanted in the future by
18 a user-defined choice, directly in the user interface.
19 """
20 @spec get_default_billing_increment() :: String.t()
21 def get_default_billing_increment do
22 1 Atom.to_string(@default_billing_increment)
23 end
24
25 @doc """
26 Constructs a list of time units, ready to be used in an `options` input element.
27
28 Returns list of tuples of the user-facing unit name and string version of the
29 time unit atom, shaped for use in Phoenix-constructed [HTML] option input elements.
30 Each tuple has two elements, the first human-readable value, the second
31 is the string version of the time unit atom name.
32
33 For example, weeks would be presented as: `{"Weeks", "week"}`.
34
35 ## Examples
36
37 iex> Klepsidra.TimeTracking.TimeUnits.construct_duration_unit_options_list()
38 [
39 {"Minutes", "minute"},
40 {"5 min", "five_minute_increment"},
41 {"6 min", "six_minute_increment"},
42 {"10 min", "ten_minute_increment"},
43 {"12 min", "twelve_minute_increment"},
44 {"15 min", "fifteen_minute_increment"},
45 {"18 min", "eighteen_minute_increment"},
46 {"20 min", "twenty_minute_increment"},
47 {"24 min", "twenty_four_minute_increment"},
48 {"30 min", "thirty_minute_increment"},
49 {"36 min", "thirty_six_minute_increment"},
50 {"45 min", "fourty_five_minute_increment"},
51 {"60 min", "sixty_minute_increment"},
52 {"90 min", "ninety_minute_increment"},
53 {"2 hour increment", "one_hundred_twenty_minute_increment"}
54 ]
55
56 iex> Klepsidra.TimeTracking.TimeUnits.construct_duration_unit_options_list(use_primitives?: true)
57 [{"Seconds", "second"}, {"Minutes", "minute"}, {"Hours", "hour"}]
58 """
59 @spec construct_duration_unit_options_list() :: [{String.t(), String.t()}]
60 def construct_duration_unit_options_list(opts \\ []) do
61 4 use_time_primitives? = Keyword.get(opts, :use_primitives?, false)
62
63 4 case use_time_primitives? do
64 2 true ->
65 [{"Seconds", "second"}, {"Minutes", "minute"}, {"Hours", "hour"}]
66
67 2 false ->
68 [
69 {"Minutes", "minute"}
70 | AdditionalUnits.units_for(@locale, @style)
71 32 |> Enum.map(fn {k, v} -> {v.display_name, Atom.to_string(k)} end)
72 ]
73 end
74 end
75 end

lib/klepsidra/time_tracking/timer.ex

72.1
104
1035
29
Line Hits Source
0 defmodule Klepsidra.TimeTracking.Timer do
1 @moduledoc """
2 Defines the `timers` schema and functions needed to clock in, out and
3 parse datetimes.
4 """
5
6 use Private
7 use Ecto.Schema
8
9 import Ecto.Changeset
10 alias Klepsidra.BusinessPartners.BusinessPartner
11 alias Klepsidra.Cldr.Unit
12 alias Klepsidra.Projects.Project
13 alias Klepsidra.TimeTracking.ActivityType
14
15 @primary_key {:id, Ecto.UUID, autogenerate: true}
16 @foreign_key_type Ecto.UUID
17
18 @typedoc """
19 A duration tuple carries the integer magnitude of the time duration as the first item,
20 and a string encoding of the time increment atom recognised by the system.
21 """
22 @type duration_tuple :: {integer(), bitstring()}
23
24 @type t :: %__MODULE__{
25 id: Ecto.UUID.t(),
26 start_stamp: String.t(),
27 end_stamp: String.t(),
28 duration: integer,
29 duration_time_unit: String.t(),
30 description: String.t(),
31 project_id: integer,
32 billable: boolean,
33 business_partner_id: integer,
34 activity_type_id: String.t(),
35 billing_rate: number(),
36 billing_duration: integer,
37 billing_duration_time_unit: String.t(),
38 inserted_at: String.t(),
39 updated_at: String.t()
40 }
41 286 schema "timers" do
42 field(:start_stamp, :string)
43 field(:end_stamp, :string)
44 field(:duration, :integer, default: nil)
45 field(:duration_time_unit, :string)
46 field(:description, :string)
47
48 belongs_to(:project, Project, type: Ecto.UUID)
49
50 field(:billable, :boolean, default: false)
51
52 belongs_to(:business_partner, BusinessPartner, type: Ecto.UUID)
53 belongs_to(:activity_type, ActivityType, type: Ecto.UUID)
54
55 field(:billing_rate, :decimal)
56 field(:billing_duration, :integer)
57 field(:billing_duration_time_unit, :string)
58
59 many_to_many(:tags, Klepsidra.Categorisation.Tag,
60 join_through: "timer_tags",
61 on_replace: :delete,
62 preload_order: [asc: :name]
63 )
64
65 has_many(:notes, Klepsidra.TimeTracking.Note, on_delete: :delete_all)
66
67 timestamps()
68 end
69
70 @doc false
71 def changeset(timer, attrs) do
72 timer
73 |> cast(attrs, [
74 :start_stamp,
75 :end_stamp,
76 :duration,
77 :duration_time_unit,
78 :description,
79 :project_id,
80 :billable,
81 :business_partner_id,
82 :activity_type_id,
83 :billing_rate,
84 :billing_duration,
85 :billing_duration_time_unit
86 ])
87 |> validate_required(:start_stamp, message: "Enter a start date and time")
88 |> validate_timestamps_and_chronology(:start_stamp, :end_stamp)
89 19 |> unique_constraint(:project)
90 end
91
92 @default_date_format Application.compile_env(:klepsidra, [__MODULE__, :default_date_format])
93 @default_time_format Application.compile_env(:klepsidra, [__MODULE__, :default_time_format])
94
95 @doc """
96 Validate that the `end_timestamp` is chronologically after the `start_timestamp`.
97
98 ## Options
99
100 * `:message` - the message on failure, defaults to "Timestamps are not in valid order"
101
102 """
103 @spec validate_timestamps_and_chronology(
104 changeset :: Ecto.Changeset.t(),
105 start_timestamp :: atom,
106 end_timestamp :: atom,
107 opts :: Keyword.t()
108 ) :: Ecto.Changeset.t()
109 def validate_timestamps_and_chronology(changeset, start_timestamp, end_timestamp, opts \\ []) do
110 19 _message = Keyword.get(opts, :message, "Timestamps are not in valid order")
111 19 start_stamp = get_field(changeset, start_timestamp, "")
112
113 19 parsed_start_stamp =
114 case parse_html_datetime(start_stamp) do
115 17 {:ok, start_datetime_stamp} -> start_datetime_stamp
116 2 _ -> nil
117 end
118
119 19 end_stamp = get_field(changeset, end_timestamp, "") || ""
120
121 19 parsed_end_stamp =
122 case parse_html_datetime(end_stamp) do
123 16 {:ok, end_datetime_stamp} -> end_datetime_stamp
124 3 _ -> nil
125 end
126
127 19 with {:is_valid, true} <- {:is_valid, changeset.valid?},
128 16 {:nonempty_start_stamp, true} <-
129 {:nonempty_start_stamp, start_stamp != ""},
130 16 {:valid_start_stamp, true} <-
131 16 {:valid_start_stamp, is_struct(parsed_start_stamp, NaiveDateTime)},
132 16 {:nonempty_end_stamp, true} <- {:nonempty_end_stamp, end_stamp != ""},
133 16 {:valid_end_stamp, true} <-
134 16 {:valid_end_stamp, is_struct(parsed_end_stamp, NaiveDateTime)},
135 16 {:chronological_order, true} <-
136 {:chronological_order, NaiveDateTime.before?(parsed_start_stamp, parsed_end_stamp)},
137 16 {:reasonable_duration_check, true} <-
138 {:reasonable_duration_check,
139 NaiveDateTime.before?(
140 parsed_end_stamp,
141 NaiveDateTime.add(parsed_start_stamp, 24, :hour)
142 )} do
143 16 changeset
144 else
145 {:is_valid, false} ->
146 3 changeset
147
148 {:nonempty_start_stamp, false} ->
149 0 add_error(changeset, :end_stamp, "You must provide a start time and date")
150
151 {:valid_start_stamp, false} ->
152 0 add_error(changeset, :end_stamp, "The start time and date is not valid")
153
154 {:nonempty_end_stamp, false} ->
155 0 changeset
156
157 {:valid_end_stamp, false} ->
158 0 add_error(changeset, :end_stamp, "The end time and date is not valid")
159
160 {:chronological_order, false} ->
161 0 add_error(changeset, :end_stamp, "The end time must follow the start time")
162
163 {:reasonable_duration_check, false} ->
164 0 add_error(changeset, :end_stamp, "The timed activity cannot be longer than one day")
165 end
166 end
167
168 @doc """
169 Get the current local date and time, without a timezone component,
170 for the timezone the the computer the program is running on is set to.
171
172 This function will display what the date and time are right now, for the
173 time zone configuration the computer is localised to.
174
175 Relying on this function to return the time is fine for many uses, including for
176 timing tasks, but is unsuitable for use where real precision and time awareness may be
177 critical.
178
179 Returns a `NaiveDateTime` struct.
180
181 ## Examples
182
183 iex> naivedatetime_stamp = Klepsidra.TimeTracking.Timer.get_current_timestamp()
184 iex> naivedatetime_stamp.year >= 2024
185 true
186 """
187 @spec get_current_timestamp() :: NaiveDateTime.t()
188 def get_current_timestamp do
189 7 NaiveDateTime.local_now()
190 end
191
192 @doc """
193 Calculates the time elapsed between start and end timestamps.
194
195 The time unit can be passed in as the optional `unit` argument. If it is omitted,
196 minutes are used as the default time unit.
197
198 In calculating the time duration, the difference between the two timestamps is
199 always incremented by one. This ensures that if the timer were simply started
200 and immediately stopped, it would still register the use of one unit of time.
201
202 If the start and end `datetime` stamps are empty strings, or nil values, returns
203 zero duration to reduce the number of error conditions.
204
205 ## Examples
206
207 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:45")
208 72
209
210 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:45", :minute)
211 72
212
213 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:45", :second)
214 4261
215
216 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:45", :hour)
217 2
218
219 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:34", :hour)
220 2
221
222 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration("2024-02-28 12:34", "2024-02-28 13:33", :hour)
223 1
224
225 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration(~N[2024-06-06 23:40:31], ~N[2024-06-07 01:23:45])
226 104
227
228 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration(~N[2024-06-06 23:40:31], ~N[2024-06-07 01:23:45], :minute)
229 104
230
231 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration(~N[2024-06-06 23:40:31], ~N[2024-06-07 01:23:45], :second)
232 6195
233
234 iex> Klepsidra.TimeTracking.Timer.calculate_timer_duration(~N[2024-06-06 23:40:31], ~N[2024-06-07 01:23:45], :hour)
235 2
236 """
237 @spec calculate_timer_duration(String.t(), String.t(), atom()) :: integer()
238 @spec calculate_timer_duration(NaiveDateTime.t(), NaiveDateTime.t(), atom()) :: integer()
239 2 def calculate_timer_duration(start_timestamp, end_timestamp, unit \\ :minute)
240
241 0 def calculate_timer_duration("", "", _unit), do: 0
242 0 def calculate_timer_duration(nil, nil, _unit), do: 0
243
244 def calculate_timer_duration(start_timestamp, end_timestamp, unit)
245 when is_bitstring(start_timestamp) and is_bitstring(end_timestamp) and is_atom(unit) do
246 6 calculate_timer_duration(
247 parse_html_datetime!(start_timestamp),
248 parse_html_datetime!(end_timestamp),
249 unit
250 )
251 end
252
253 def calculate_timer_duration(start_timestamp, end_timestamp, unit)
254 when is_struct(start_timestamp, NaiveDateTime) and
255 is_struct(
256 end_timestamp,
257 NaiveDateTime
258 ) and is_atom(unit) do
259 12 with {:end_follows_start, true} <-
260 {:end_follows_start, NaiveDateTime.after?(end_timestamp, start_timestamp)},
261 12 {:uses_time_unit_primitive, true} <-
262 12 {:uses_time_unit_primitive, unit in [:second, :minute, :hour, :day]} do
263 12 NaiveDateTime.diff(end_timestamp, start_timestamp, unit) + 1
264 else
265 0 {:end_follows_start, false} ->
266 0
267
268 {:uses_time_unit_primitive, false} ->
269 (NaiveDateTime.diff(end_timestamp, start_timestamp, :minute) + 1)
270 |> Cldr.Unit.new!(:minute)
271 |> Klepsidra.Cldr.Unit.convert!(unit)
272 |> Map.get(:value)
273 |> Decimal.round(0, :up)
274 0 |> Decimal.to_integer()
275 end
276 end
277
278 @doc """
279 Clock out of an active timer, given a starting timestamp string.
280
281 ## Return values
282
283 Returns a map containing the ending timestamp and duration in the requested unit of time.
284
285 ## Examples
286
287 iex> Klepsidra.TimeTracking.Timer.get_current_timestamp()
288 ...> |> NaiveDateTime.add(-15, :minute)
289 ...> |> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!()
290 ...> |> Klepsidra.TimeTracking.Timer.clock_out()
291 %{end_timestamp: Klepsidra.TimeTracking.Timer.get_current_timestamp() |> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!(), timer_duration: 16}
292
293 iex> Klepsidra.TimeTracking.Timer.get_current_timestamp()
294 ...> |> NaiveDateTime.add(-15, :minute)
295 ...> |> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!()
296 ...> |> Klepsidra.TimeTracking.Timer.clock_out(:hour)
297 %{end_timestamp: Klepsidra.TimeTracking.Timer.get_current_timestamp() |> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!(), timer_duration: 1}
298 """
299 @spec clock_out(String.t(), atom()) :: %{end_timestamp: String.t(), timer_duration: integer()}
300 1 def clock_out(start_timestamp, unit \\ :minute)
301 when is_bitstring(start_timestamp) and is_atom(unit) do
302 2 end_timestamp = get_current_timestamp()
303
304 2 %{
305 end_timestamp: convert_naivedatetime_to_html!(end_timestamp),
306 timer_duration:
307 calculate_timer_duration(
308 parse_html_datetime!(start_timestamp),
309 end_timestamp,
310 unit
311 )
312 }
313 end
314
315 @doc """
316 Parses HTML `datetime-local` strings into `NativeDateTime` structure.
317
318 Datetime strings coming from HTML, from `datetime-local` type fields,
319 are not conformant to the extended date and time of day ISO 8601:2019 standard format.
320 Specifically, they are encoded as "YYYY-MM-DDThh:mm", and are generally (but not always)
321 passed without a seconds component. `NativeDateTime` cannot parse this, returning an
322 error.
323
324 Using the Timex library's `parse/2` function, parse datetime strings into an ISO
325 conforming `NativeDateTime` structure, returning a result tuple:
326
327 `{:ok, ~N[...]}` on success, or {:error, reason} upon failure.
328
329 It is possible to receive a datetime with date and time components separated by either
330 a letter "t" or a single space (" "), binary pattern matching will determine which is
331 received in the `datetime_string` argument.
332
333 An error is returned if the datetime string cannot be parsed as a valid date and time,
334 and also if the string doesn't match the expected pattern.
335
336 ## Examples
337
338 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-01-01T11:15")
339 {:ok, ~N[1970-01-01 11:15:00]}
340
341 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-01-01T11:15:39")
342 {:ok, ~N[1970-01-01 11:15:39]}
343
344 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-01-01 11:15")
345 {:ok, ~N[1970-01-01 11:15:00]}
346
347 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-01-01 11:15:59")
348 {:ok, ~N[1970-01-01 11:15:59]}
349
350 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("1970-02-29T11:15")
351 {:error, :invalid_date}
352
353 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime("")
354 {:error, "Invalid argument passed as timestamp"}
355
356 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime(nil)
357 {:error, "Invalid argument passed as timestamp"}
358 """
359 @spec parse_html_datetime(String.t()) :: {:ok, NaiveDateTime.t()} | {:error, String.t()}
360 def parse_html_datetime(
361 <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), "T",
362 _hour::binary-size(2), ":", _minute::binary-size(2)>> = datetime_string
363 )
364 when is_bitstring(datetime_string) do
365 4 Timex.parse(datetime_string, "{YYYY}-{0M}-{0D}T{0h24}:{0m}")
366 end
367
368 def parse_html_datetime(
369 <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), " ",
370 _hour::binary-size(2), ":", _minute::binary-size(2)>> = datetime_string
371 )
372 when is_bitstring(datetime_string) do
373 17 Timex.parse(datetime_string, "{YYYY}-{0M}-{0D} {0h24}:{0m}")
374 end
375
376 def parse_html_datetime(
377 <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), "T",
378 _hour::binary-size(2), ":", _minute::binary-size(2), ":",
379 _second::binary-size(2)>> = datetime_string
380 )
381 when is_bitstring(datetime_string) do
382 1 Timex.parse(datetime_string, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}")
383 end
384
385 def parse_html_datetime(
386 <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), " ",
387 _hour::binary-size(2), ":", _minute::binary-size(2), ":",
388 _second::binary-size(2)>> = datetime_string
389 )
390 when is_bitstring(datetime_string) do
391 16 Timex.parse(datetime_string, "{YYYY}-{0M}-{0D} {0h24}:{0m}:{0s}")
392 end
393
394 7 def parse_html_datetime(_), do: {:error, "Invalid argument passed as timestamp"}
395
396 @doc """
397 Parses HTML `datetime-local` strings into `NativeDateTime` structure.
398
399 Works just like `parse_html_datetime\1`, but instead of returning an {:ok, _} or
400 {:error, reason} tuple, returns the `NaiveDateTime` struct on success, raising
401 an error otherwise.
402
403 ## Examples
404
405 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime!("1970-01-01T11:15")
406 ~N[1970-01-01 11:15:00]
407
408 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime!("1970-01-01T11:15:39")
409 ~N[1970-01-01 11:15:39]
410
411 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime!("1970-01-01 11:15")
412 ~N[1970-01-01 11:15:00]
413
414 iex> Klepsidra.TimeTracking.Timer.parse_html_datetime!("1970-01-01 11:15:59")
415 ~N[1970-01-01 11:15:59]
416
417 """
418 @spec parse_html_datetime!(String.t()) :: NaiveDateTime.t()
419 def parse_html_datetime!(
420 <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), "T",
421 _hour::binary-size(2), ":", _minute::binary-size(2)>> = datetime_string
422 )
423 when is_bitstring(datetime_string) do
424 1 Timex.parse!(datetime_string, "{YYYY}-{0M}-{0D}T{0h24}:{0m}")
425 end
426
427 def parse_html_datetime!(
428 <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), " ",
429 _hour::binary-size(2), ":", _minute::binary-size(2)>> = datetime_string
430 )
431 when is_bitstring(datetime_string) do
432 21 Timex.parse!(datetime_string, "{YYYY}-{0M}-{0D} {0h24}:{0m}")
433 end
434
435 def parse_html_datetime!(
436 <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), "T",
437 _hour::binary-size(2), ":", _minute::binary-size(2), ":",
438 _second::binary-size(2)>> = datetime_string
439 )
440 when is_bitstring(datetime_string) do
441 3 Timex.parse!(datetime_string, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}")
442 end
443
444 def parse_html_datetime!(
445 <<_year::binary-size(4), "-", _month::binary-size(2), "-", _day::binary-size(2), " ",
446 _hour::binary-size(2), ":", _minute::binary-size(2), ":",
447 _second::binary-size(2)>> = datetime_string
448 )
449 when is_bitstring(datetime_string) do
450 9 Timex.parse!(datetime_string, "{YYYY}-{0M}-{0D} {0h24}:{0m}:{0s}")
451 end
452
453 @doc """
454 Converts `NativeDateTime` structure to HTML-ready string, with the seconds component
455 elided.
456
457 Returns a tuple with `:ok` or `:error` as the first element, with a string
458 compatible with HTML's input `datetime-local` element, in the format
459 "YYYY-MM-DDThh:mm". This can directly be fed into an `input` element's `value`
460 slot.
461
462 ## Examples
463
464 iex> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html(~N[2024-04-07 22:12:32])
465 {:ok, "2024-04-07T22:12:32"}
466 """
467 @spec convert_naivedatetime_to_html(NaiveDateTime.t()) ::
468 {:ok, String.t()} | {:error, String.t()}
469 def convert_naivedatetime_to_html(datetime_stamp)
470 when is_struct(datetime_stamp, NaiveDateTime) do
471 1 Timex.format(datetime_stamp, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}")
472 end
473
474 @doc """
475 Converts `NativeDateTime` structure to HTML-ready string, with the seconds component
476 elided.
477
478 Returns a string compatible with HTML's input `datetime-local` element, in the
479 format "YYYY-MM-DDThh:mm". This can directly be fed into an `input` element's
480 `value` slot.
481
482 ## Examples
483
484 iex> Klepsidra.TimeTracking.Timer.convert_naivedatetime_to_html!(~N[2024-04-07 22:12:32])
485 "2024-04-07T22:12:32"
486 """
487 @spec convert_naivedatetime_to_html!(NaiveDateTime.t()) :: String.t()
488 def convert_naivedatetime_to_html!(datetime_stamp)
489 when is_struct(datetime_stamp, NaiveDateTime) do
490 7 Timex.format!(datetime_stamp, "{YYYY}-{0M}-{0D}T{0h24}:{0m}:{0s}")
491 end
492
493 @doc """
494 Formats a number into a string according to a unit definition for a locale.
495
496 Takes an integer duration, and an atom time unit, including any custom time
497 units defined and compiled as part of this project.
498
499 Returns a tuple {:ok, ...} containing a locale-specific and quantity-sensitive
500 pluralisation of the defined time unit as a string.
501
502 ## Examples
503
504 iex> Klepsidra.TimeTracking.Timer.duration_to_string(3, :minute)
505 {:ok, "3 minutes"}
506
507 iex> Klepsidra.TimeTracking.Timer.duration_to_string(7, :six_minute_increment)
508 {:ok, "7 six minute increments"}
509
510 iex> Klepsidra.TimeTracking.Timer.duration_to_string(1, :hour)
511 {:ok, "1 hour"}
512
513 iex> Klepsidra.TimeTracking.Timer.duration_to_string(0, :second)
514 {:ok, "0 seconds"}
515 """
516 @spec duration_to_string(duration :: integer(), time_unit :: atom()) ::
517 {:ok, String.t()} | {:error, {atom(), String.t()}}
518 def duration_to_string(duration, time_unit) when is_integer(duration) and is_atom(time_unit) do
519 4 Cldr.Unit.to_string(Cldr.Unit.new!(time_unit, duration))
520 end
521
522 @doc """
523 Used across `timer` live components to calculate timer durations.
524
525 The function takes the `timer_params` parameters passed to the validation function,
526 extracts the start and end datetime stamps, returning a map with the two
527 calculated durations: `%{duration: 0, billing_duration: 0}`
528 """
529 @spec assign_timer_duration(%{optional(any) => any}, String.t()) :: integer()
530 def assign_timer_duration(timer_params, duration_time_unit)
531 when is_map(timer_params) and is_bitstring(duration_time_unit) do
532 0 start_stamp = timer_params["start_stamp"]
533 0 end_stamp = timer_params["end_stamp"]
534 0 duration_time_unit = timer_params[duration_time_unit]
535
536 0 with true <- start_stamp != "",
537 0 true <- end_stamp != "",
538 0 true <- duration_time_unit != "" do
539 0 calculate_timer_duration(
540 start_stamp,
541 end_stamp,
542 String.to_atom(duration_time_unit)
543 )
544 else
545 _ -> 0
546 end
547 end
548
549 def read_checkbox(field) do
550 0 Phoenix.HTML.Form.normalize_value("checkbox", field)
551 end
552
553 @doc """
554 Takes in a single duration tuple, shaped as `{duration, string_duration_time_unit}`,
555 converting it to a duration in seconds, the base time unit.
556 """
557 @spec convert_duration_to_base_time_unit(duration_tuple :: duration_tuple()) ::
558 Cldr.Unit.t()
559 def convert_duration_to_base_time_unit(duration_tuple)
560 when is_tuple(duration_tuple) and tuple_size(duration_tuple) == 2 do
561 8 {duration, duration_time_unit} = duration_tuple
562
563 Unit.new!(duration, convert_string_to_time_unit_atom(duration_time_unit))
564 8 |> Unit.convert!(:second)
565 end
566
567 @doc """
568 Takes in a list of duration tuples, shaped as `{duration, string_duration_time_unit}`,
569 converting them all to durations in seconds, the base time unit.
570 """
571 @spec convert_durations_to_base_time_unit(durations_list :: [duration_tuple(), ...]) :: [
572 Cldr.Unit.t(),
573 ...
574 ]
575 6 def convert_durations_to_base_time_unit([]), do: []
576
577 def convert_durations_to_base_time_unit(durations_list)
578 when is_list(durations_list) do
579 durations_list
580 0 |> Enum.map(fn duration_tuple -> convert_duration_to_base_time_unit(duration_tuple) end)
581 end
582
583 @spec convert_string_to_time_unit_atom(String.t()) :: atom()
584 defp convert_string_to_time_unit_atom(time_unit) when is_bitstring(time_unit) do
585 8 cond do
586 8 time_unit == "minute" -> :minute_increment
587 0 time_unit == "hour" -> :hour_increment
588 0 true -> String.to_existing_atom(time_unit)
589 end
590 end
591
592 @doc """
593 Takes a list of `Cldr.Unit` structures, timed in the base unit for time,
594 seconds, summing them all to return a total duration in the same time unit.
595 """
596 @spec sum_base_unit_durations(durations_list :: [Cldr.Unit.t(), ...]) :: Cldr.Unit.t()
597 def sum_base_unit_durations(durations_list)
598 when is_list(durations_list) do
599 durations_list
600 6 |> Enum.reduce(Unit.new!(:second, 0), fn i, acc ->
601 0 Unit.add(i, acc)
602 end)
603 end
604
605 @doc """
606 Takes two `Cldr.Unit` structures, the aggregate time and the latest deleted timer,
607 timed in, seconds, the base unit of time, returning the result of their difference.
608 """
609 @spec subtract_base_unit_durations(duration_1 :: map(), duration_2 :: map()) :: Cldr.Unit.t()
610 def subtract_base_unit_durations(duration_1, duration_2)
611 when is_struct(duration_1, Cldr.Unit) and is_struct(duration_2, Cldr.Unit) do
612 0 Unit.sub!(duration_1, duration_2)
613 end
614
615 @doc """
616 Takes in a `Cldr.Unit` structure, denoting a duration, decomposing it into
617 human-intuitive time increments, rounding it to the nearest of each unit,
618 formatting it all as an easy to read string.
619
620 By default, the decomposition will be into hours and minutes, but a list
621 consisting of any time increment can be used here, e.g.:
622 `[:day, :hour_increment, :minute_increment]`.
623
624 A zero time value will return a nil result.
625
626 ## Examples
627
628 #iex> 3600 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration()
629 #"1 hour"
630 #iex> 0 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration()
631 #nil
632 #iex> 5000 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration()
633 #"1 hour and 23 minutes"
634 #iex> 95000 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration(unit_list: [:hour_increment, :minute_increment])
635 #"26 hours and 23 minutes"
636 #iex> 95000 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration(unit_list: [:day, :hour_increment, :minute_increment])
637 #"1 day, 2 hours and 23 minutes"
638 #iex> 95000 |> Cldr.Unit.new!(:second) |> Klepsidra.TimeTracking.Timer.format_human_readable_duration(unit_list: [:day, :hour_increment])
639 #"1 day and 2 hours"
640 """
641 @spec format_human_readable_duration(duration :: Cldr.Unit.t(), options :: keyword()) ::
642 nil | bitstring()
643 8 def format_human_readable_duration(duration, options \\ [])
644 when is_struct(duration, Cldr.Unit) do
645 14 unit_list =
646 Keyword.get(options, :unit_list, [:hour_increment, :minute_increment])
647
648 14 restrict_if_components_only =
649 Keyword.get(options, :restrict_if_components_only, false)
650
651 14 case decompose_unit(duration, unit_list,
652 restrict_if_components_only: restrict_if_components_only
653 ) do
654 0 nil ->
655 nil
656
657 unit_composition ->
658 unit_composition
659 8 |> Enum.map(fn i -> Unit.round(i, 0) end)
660 14 |> then(fn
661 6 [] -> nil
662 8 list -> Unit.to_string!(list)
663 end)
664 end
665 end
666
667 @doc """
668 Decompose a unit into component subunits.
669
670 Any list compatible units can be provided however a list of units of
671 decreasing scale is recommended.
672
673 ## Arguments
674
675 * `unit` is any unit returned by `Cldr.Unit.new/2`
676 * `subunit_list` is a list of valid units. All units must be from the same
677 category
678
679 ## Returns
680
681 A map containing:
682
683 * `number_of_subunits` the number of component subunits
684 * `unit_composition` a list of units after decomposition
685
686 ## Examples
687
688 iex> 21.17 |> Cldr.Unit.new!(:hour_increment) |> Klepsidra.TimeTracking.Timer.decompose_unit([:day, :hour_increment])
689 [Cldr.Unit.new!(:hour_increment, "21.1699999999999992")]
690
691 iex> 121.17 |> Cldr.Unit.new!(:hour_increment) |> Klepsidra.TimeTracking.Timer.decompose_unit([:day, :hour_increment])
692 [Cldr.Unit.new!(:day, 5), Cldr.Unit.new!(:hour_increment, "1.17")]
693
694 iex> 21.17 |> Klepsidra.TimeTracking.Timer.decompose_unit([:day, :hour_increment])
695 {:error, "Invalid unit or subunit_list"}
696
697 iex> 21.17 |> Cldr.Unit.new!(:hour_increment) |> Klepsidra.TimeTracking.Timer.decompose_unit("")
698 {:error, "Invalid unit or subunit_list"}
699 """
700 @spec decompose_unit(unit :: Cldr.Unit.t(), subunit_list :: list(), options :: keyword()) ::
701 nil | list(any())
702 @spec decompose_unit(unit :: any(), subunit_list :: any(), options :: keyword()) ::
703 {:error, String.t()}
704 4 def decompose_unit(unit, subunit_list, options \\ [])
705
706 def decompose_unit(unit, subunit_list, options)
707 when is_struct(unit, Cldr.Unit) and is_list(subunit_list) do
708 16 restrict_if_components_only = Keyword.get(options, :restrict_if_components_only, false)
709
710 Unit.decompose(unit, subunit_list)
711 16 |> adjust_for_restricted_subunits(restrict_if_components_only)
712 end
713
714 2 def decompose_unit(_unit, _subunit_list, _options),
715 do: {:error, "Invalid unit or subunit_list"}
716
717 private do
718 @spec adjust_for_restricted_subunits(
719 unit_composition :: [Cldr.Unit.t(), ...],
720 restricted_subunits :: list()
721 ) ::
722 nil | list()
723 def adjust_for_restricted_subunits(unit_composition, [_ | _] = restricted_subunits)
724 when is_list(unit_composition) do
725 5 restricted_list = MapSet.new(restricted_subunits)
726
727 unit_composition
728 9 |> Enum.reject(fn %{unit: unit} -> MapSet.member?(restricted_list, unit) end)
729 5 |> non_empty_list?(unit_composition)
730 end
731
732 16 def adjust_for_restricted_subunits(unit_composition, _), do: unit_composition
733 end
734
735 private do
736 @spec non_empty_list?(list :: nonempty_list(), return :: list()) :: as_boolean(term)
737 5 def non_empty_list?([], _), do: nil
738 2 def non_empty_list?(list, []) when is_list(list), do: list
739 1 def non_empty_list?(_list, return) when is_list(return), do: return
740 end
741
742 @doc """
743 Format a `NaiveDateTime` into human readable date.
744
745 ## Examples
746
747 iex> Klepsidra.TimeTracking.Timer.format_human_readable_date(~N[2024-01-23 12:34:56])
748 {:ok, "Tuesday, 23 Jan 2024"}
749 """
750 @spec format_human_readable_date(NaiveDateTime.t()) ::
751 {:ok, String.t()} | {:error, {atom(), String.t()}}
752 2 def format_human_readable_date(datetime, format_string \\ @default_date_format)
753 when is_struct(datetime, NaiveDateTime) and is_bitstring(format_string) do
754 2 Timex.format(datetime, format_string)
755 end
756
757 @doc """
758 Format a `NaiveDateTime` into a human-readable time, displaying hours and minutes by default, in 24-hour time, returning a tuple with the return status and the formatted time string.
759
760 ## Arguments
761
762 * `datetime`, a valid `NaiveDateTime` structure
763 * `format_string`, an optional format string, in `Timex` default formatting language.
764
765 ## Returns
766
767 A tuple with return status and a string, either
768
769 * {:ok, formatted time string}
770
771 or
772
773 * {:error, error message}
774
775 ## Examples
776
777 iex> Klepsidra.TimeTracking.Timer.format_human_readable_time(~N[2024-01-23 12:34:56])
778 {:ok, "12:34"}
779 """
780 @spec format_human_readable_time(NaiveDateTime.t()) :: {:ok | :error, bitstring()}
781 7 def format_human_readable_time(datetime, format_string \\ @default_time_format)
782 when is_struct(datetime, NaiveDateTime) and is_bitstring(format_string) do
783 5 Timex.format(datetime, format_string)
784 end
785
786 @doc """
787 Format a `NaiveDateTime` into a human-readable time, displaying hours and minutes by default, in 24-hour time.
788
789 ## Arguments
790
791 * `datetime`, a valid `NaiveDateTime` structure
792 * `format_string`, an optional format string, in `Timex` default formatting language.
793
794 ## Returns
795
796 * formatted time string, or an exception on error
797
798 ## Examples
799
800 iex> Klepsidra.TimeTracking.Timer.format_human_readable_time!(~N[2024-01-23 12:34:56])
801 "12:34"
802 """
803 @spec format_human_readable_time!(NaiveDateTime.t()) :: bitstring()
804 23 def format_human_readable_time!(datetime, format_string \\ @default_time_format)
805 when is_struct(datetime, NaiveDateTime) and is_bitstring(format_string) do
806 22 Timex.format!(datetime, format_string)
807 end
808
809 @spec calculate_aggregate_duration_for_timers(timers :: [duration_tuple(), ...]) :: %{
810 base_unit_duration: Cldr.Unit.t(),
811 duration_in_hours: bitstring(),
812 human_readable_duration: bitstring() | nil
813 }
814 def calculate_aggregate_duration_for_timers(timers) when is_list(timers) do
815 timers
816 |> convert_durations_to_base_time_unit()
817 |> sum_base_unit_durations()
818 0 |> format_aggregate_duration_for_project()
819 end
820
821 @spec format_aggregate_duration_for_project(base_unit_duration :: Cldr.Unit.t()) :: %{
822 base_unit_duration: Cldr.Unit.t(),
823 duration_in_hours: String.t(),
824 human_readable_duration: String.t() | nil
825 }
826 def format_aggregate_duration_for_project(base_unit_duration)
827 when is_struct(base_unit_duration, Cldr.Unit) do
828 0 duration_in_hours =
829 base_unit_duration
830 |> Klepsidra.Cldr.Unit.convert!(:hour_increment)
831 0 |> then(fn i -> Cldr.Unit.round(i, 1) end)
832 |> Klepsidra.Cldr.Unit.to_string!()
833
834 0 duration_in_dhm_format =
835 format_human_readable_duration(base_unit_duration,
836 unit_list: [
837 :day,
838 :hour_increment,
839 :minute_increment
840 ],
841 return_if_short_duration: false
842 )
843
844 0 %{
845 base_unit_duration: base_unit_duration,
846 duration_in_hours: duration_in_hours,
847 human_readable_duration: duration_in_dhm_format
848 }
849 end
850 end

lib/klepsidra/utilities/math.ex

100.0
15
1940
0
Line Hits Source
0 defmodule Klepsidra.Math do
1 @moduledoc """
2 Utilities module defining mathematical functionality, commonly used
3 all across the project.
4
5 This is a supplement to the inbuilt maths fuctions
6 in the `Kernel` module, but also `Decimal` and `Cldr.Unit.Math`,
7 providing highly specialised functions, working across a range of used
8 units.
9 """
10
11 use Private
12
13 @doc """
14 Calculates the arithmetic mean, usually referred to as the average,
15 given a sum and count of items.
16
17 As the functionality inevitably depends on division, eager pattern-matching
18 is used in function heads, immediately returning zero, preventing divide by
19 zero errors.
20
21 ## Arguments
22
23 * `sum` is any valid `Cldr.Unit`, integer or float, and is the numerator
24 * `count` is an integer only, used for a discrete number of events
25
26 ## Returns
27
28 * The arithmetic mean, in integer or float format
29
30 ## Examples
31
32 iex> Math.arithmetic_mean(Cldr.Unit.new!(91.0, :second), 13)
33 Cldr.Unit.new!(:second, 7)
34
35 iex> Math.arithmetic_mean(Cldr.Unit.new!(0, :second), 13)
36 Cldr.Unit.new!(:second, 0)
37
38 iex> Math.arithmetic_mean(Cldr.Unit.new!(7, :second), 0)
39 0
40
41 iex> Math.arithmetic_mean(13, 2)
42 6.5
43
44 iex> Math.arithmetic_mean(13, 0)
45 0
46 """
47 @spec arithmetic_mean(
48 sum :: Cldr.Unit.t(),
49 count :: integer()
50 ) :: number()
51 @spec arithmetic_mean(
52 sum :: number() | any(),
53 count :: integer()
54 ) :: number()
55 102 def arithmetic_mean(%Cldr.Unit{} = _sum, 0), do: 0
56 3 def arithmetic_mean(_sum, 0), do: 0
57
58 def arithmetic_mean(%Cldr.Unit{} = sum, count)
59 when is_integer(count) do
60 104 multi_unit_div(sum, count)
61 end
62
63 def arithmetic_mean(sum, count)
64 when is_number(sum) and is_integer(count) do
65 3 multi_unit_div(sum, count)
66 end
67
68 @doc """
69 Divides multiple types of units not directly covered in the `Kernel`,
70 `Decimal` and `Cldr.Unit` modules.
71
72 As division by zero is illegal, eager pattern-matching is used in
73 function heads, immediately returning zero or equivalent, preventing
74 raising of errors.
75
76 ## Arguments
77
78 * `numerator`, which is a number, `Decimal`, or `Cldr.Unit` type
79 * `denominator`, a number or `Decimal` type
80
81 ## Returns
82
83 * Result of the division, in float, `Decimal`, or `Cldr.Unit` type
84
85 ## Examples
86
87 iex> Math.multi_unit_div(300, 13)
88 23.076923076923077
89 """
90 @spec multi_unit_div(
91 numerator :: number() | Decimal.t() | Cldr.Unit.t(),
92 denominator :: number() | Decimal.t()
93 ) :: float() | Decimal.t() | Cldr.Unit.t()
94 201 def multi_unit_div(%Decimal{} = _numerator, 0), do: Decimal.new(0)
95 201 def multi_unit_div(%Decimal{} = _numerator, +0.0), do: Decimal.new("0.0")
96 4 def multi_unit_div(_numerator, %Decimal{coef: 0}), do: Decimal.new(0)
97 202 def multi_unit_div(_numerator, 0), do: 0.0
98 202 def multi_unit_div(_numerator, +0.0), do: 0.0
99
100 def multi_unit_div(%Cldr.Unit{} = numerator, denominator)
101 when is_integer(denominator) or is_struct(denominator, Cldr.Unit) do
102 104 Cldr.Unit.div!(numerator, denominator)
103 end
104
105 def multi_unit_div(%Decimal{} = numerator, denominator)
106 when is_float(denominator) do
107 201 Decimal.div(numerator, Decimal.from_float(denominator))
108 end
109
110 def multi_unit_div(%Decimal{} = numerator, denominator)
111 when is_integer(denominator) do
112 201 Decimal.div(numerator, denominator)
113 end
114
115 def multi_unit_div(numerator, %Decimal{} = denominator)
116 when is_float(numerator) do
117 2 Decimal.div(Decimal.from_float(numerator), denominator)
118 end
119
120 def multi_unit_div(numerator, %Decimal{} = denominator)
121 when is_integer(numerator) do
122 2 Decimal.div(numerator, denominator)
123 end
124
125 def multi_unit_div(numerator, denominator)
126 when is_number(numerator) and is_number(denominator) do
127 408 Kernel./(numerator, denominator)
128 end
129 end

lib/klepsidra_web.ex

100.0
2
20
0
Line Hits Source
0 defmodule KlepsidraWeb do
1 @moduledoc """
2 The entrypoint for defining your web interface, such
3 as controllers, components, channels, and so on.
4
5 This can be used in your application as:
6
7 use KlepsidraWeb, :controller
8 use KlepsidraWeb, :html
9
10 The definitions below will be executed for every controller,
11 component, etc, so keep them short and clean, focused
12 on imports, uses and aliases.
13
14 Do NOT define functions inside the quoted expressions
15 below. Instead, define additional modules and import
16 those modules here.
17 """
18
19 10 def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
20
21 def router do
22 quote do
23 use Phoenix.Router, helpers: false
24
25 # Import common connection and controller functions to use in pipelines
26 import Plug.Conn
27 import Phoenix.Controller
28 import Phoenix.LiveView.Router
29
30 import KlepsidraWeb.CSP, only: [put_content_security_policy: 2]
31 end
32 end
33
34 def channel do
35 quote do
36 use Phoenix.Channel
37 end
38 end
39
40 def controller do
41 quote do
42 use Phoenix.Controller,
43 formats: [:html, :json],
44 layouts: [html: KlepsidraWeb.Layouts]
45
46 import Plug.Conn
47 use Gettext, backend: KlepsidraWeb.Gettext
48
49 unquote(verified_routes())
50 end
51 end
52
53 def live_view do
54 quote do
55 use Phoenix.LiveView,
56 layout: {KlepsidraWeb.Layouts, :app}
57
58 unquote(html_helpers())
59 end
60 end
61
62 def live_component do
63 quote do
64 use Phoenix.LiveComponent
65
66 unquote(html_helpers())
67 end
68 end
69
70 def html do
71 quote do
72 use Phoenix.Component
73
74 # Import convenience functions from controllers
75 import Phoenix.Controller,
76 only: [get_csrf_token: 0, view_module: 1, view_template: 1]
77
78 import KlepsidraWeb.CSP,
79 only: [get_csp_nonce: 0]
80
81 # Include general helpers for rendering HTML
82 unquote(html_helpers())
83 end
84 end
85
86 defp html_helpers do
87 quote do
88 # HTML escaping functionality
89 import Phoenix.HTML
90 # Core UI components and translation
91 import KlepsidraWeb.CoreComponents
92 use Gettext, backend: KlepsidraWeb.Gettext
93
94 # Shortcut for generating JS commands
95 alias Phoenix.LiveView.JS
96
97 # Routes generation with the ~p sigil
98 unquote(verified_routes())
99 end
100 end
101
102 def verified_routes do
103 quote do
104 use Phoenix.VerifiedRoutes,
105 endpoint: KlepsidraWeb.Endpoint,
106 router: KlepsidraWeb.Router,
107 statics: KlepsidraWeb.static_paths()
108 end
109 end
110
111 @doc """
112 When used, dispatch to the appropriate controller/view/etc.
113 """
114 defmacro __using__(which) when is_atom(which) do
115 10 apply(__MODULE__, which, [])
116 end
117 end

lib/klepsidra_web/components/core_components.ex

86.3
161
7669
22
Line Hits Source
0 defmodule KlepsidraWeb.CoreComponents do
1 @moduledoc """
2 Provides core UI components.
3
4 At the first glance, this module may seem daunting, but its goal is
5 to provide some core building blocks in your application, such modals,
6 tables, and forms. The components are mostly markup and well documented
7 with doc strings and declarative assigns. You may customize and style
8 them in any way you want, based on your application growth and needs.
9
10 The default components use Tailwind CSS, a utility-first CSS framework.
11 See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
12 how to customize them or feel free to swap in another framework altogether.
13
14 Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
15 """
16 use Phoenix.Component
17
18 alias Phoenix.LiveView.JS
19 use Gettext, backend: KlepsidraWeb.Gettext
20
21 @doc """
22 Renders a modal.
23
24 ## Examples
25
26 <.modal id="confirm-modal">
27 This is a modal.
28 </.modal>
29
30 JS commands may be passed to the `:on_cancel` to configure
31 the closing/cancel event, for example:
32
33 <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
34 This is another modal.
35 </.modal>
36
37 """
38 attr(:id, :string, required: true)
39 attr(:show, :boolean, default: false)
40 attr(:on_cancel, JS, default: %JS{})
41 slot(:inner_block, required: true)
42
43 def modal(assigns) do
44 19 ~H"""
45 19 <div
46 19 id={@id}
47 19 phx-mounted={@show && show_modal(@id)}
48 19 phx-remove={hide_modal(@id)}
49 19 data-cancel={JS.exec(@on_cancel, "phx-remove")}
50 class="relative z-50 hidden"
51 >
52 19 <div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
53 <div
54 class="fixed inset-0 overflow-y-auto"
55 19 aria-labelledby={"#{@id}-title"}
56 19 aria-describedby={"#{@id}-description"}
57 role="dialog"
58 aria-modal="true"
59 tabindex="0"
60 >
61 <div class="flex min-h-full items-center justify-center">
62 <div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
63 19 <.focus_wrap
64 19 id={"#{@id}-container"}
65 19 phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
66 phx-key="escape"
67 19 phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
68 class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
69 >
70 <div class="absolute top-6 right-5">
71 19 <button
72 19 phx-click={JS.exec("data-cancel", to: "##{@id}")}
73 type="button"
74 class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
75 aria-label={gettext("close")}
76 >
77 19 <.icon name="hero-x-mark-solid" class="h-5 w-5" />
78 </button>
79 </div>
80 19 <div id={"#{@id}-content"}>
81 19 <%= render_slot(@inner_block) %>
82 </div>
83 </.focus_wrap>
84 </div>
85 </div>
86 </div>
87 </div>
88 """
89 end
90
91 @doc """
92 Renders flash notices.
93
94 ## Examples
95
96 <.flash kind={:info} flash={@flash} />
97 <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
98 """
99 attr(:id, :string, default: "flash", doc: "the optional id of flash container")
100 attr(:flash, :map, default: %{}, doc: "the map of flash messages to display")
101 attr(:title, :string, default: nil)
102 attr(:kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup")
103 attr(:rest, :global, doc: "the arbitrary HTML attributes to add to the flash container")
104
105 slot(:inner_block, doc: "the optional inner block that renders the flash message")
106
107 def flash(assigns) do
108 0 ~H"""
109 0 <div
110 0 :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
111 0 id={@id}
112 0 phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
113 role="alert"
114 class={[
115 "fixed top-2 right-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
116 0 @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
117 0 @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
118 ]}
119 0 {@rest}
120 >
121 0 <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
122 0 <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
123 0 <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
124 0 <%= @title %>
125 </p>
126 <p class="mt-2 text-sm leading-5"><%= msg %></p>
127 0 <button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
128 0 <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
129 </button>
130 </div>
131 """
132 end
133
134 @doc """
135 Shows the flash group with standard titles and content.
136
137 ## Examples
138
139 <.flash_group flash={@flash} />
140 """
141 attr(:flash, :map, required: true, doc: "the map of flash messages")
142
143 def flash_group(assigns) do
144 0 ~H"""
145 0 <.flash kind={:info} title="Success!" flash={@flash} />
146 0 <.flash kind={:error} title="Error!" flash={@flash} />
147 0 <.flash
148 id="disconnected"
149 kind={:error}
150 title="We can't find the internet"
151 phx-disconnected={show("#disconnected")}
152 phx-connected={hide("#disconnected")}
153 hidden
154 >
155 0 Attempting to reconnect <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
156 </.flash>
157 """
158 end
159
160 @doc """
161 Renders a simple form.
162
163 ## Examples
164
165 <.simple_form for={@form} phx-change="validate" phx-submit="save">
166 <.input field={@form[:email]} label="Email"/>
167 <.input field={@form[:username]} label="Username" />
168 <:actions>
169 <.button>Save</.button>
170 </:actions>
171 </.simple_form>
172 """
173 attr(:for, :any, required: true, doc: "the datastructure for the form")
174 attr(:as, :any, default: nil, doc: "the server side parameter to collect all input under")
175
176 attr(:rest, :global,
177 include: ~w(autocomplete name rel action enctype method novalidate target),
178 doc: "the arbitrary HTML attributes to apply to the form tag"
179 )
180
181 slot(:inner_block, required: true)
182 slot(:actions, doc: "the slot for form actions, such as a submit button")
183
184 def simple_form(assigns) do
185 41 ~H"""
186 41 <.form :let={f} for={@for} as={@as} {@rest}>
187 <div class="mt-10 space-y-8 bg-white">
188 41 <%= render_slot(@inner_block, f) %>
189 41 <div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
190 <%= render_slot(action, f) %>
191 </div>
192 </div>
193 </.form>
194 """
195 end
196
197 @doc """
198 Renders a button.
199
200 ## Examples
201
202 <.button>Send!</.button>
203 <.button phx-click="go" class="ml-2">Send!</.button>
204 """
205 attr(:type, :string, default: nil)
206 attr(:class, :string, default: nil)
207 attr(:rest, :global, include: ~w(disabled form name value))
208
209 slot(:inner_block, required: true)
210
211 def button(assigns) do
212 218 ~H"""
213 218 <button
214 218 type={@type}
215 class={[
216 "phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
217 "text-sm font-semibold leading-6 text-white active:text-white/80",
218 218 @class
219 ]}
220 218 {@rest}
221 >
222 218 <%= render_slot(@inner_block) %>
223 </button>
224 """
225 end
226
227 @doc """
228 Renders an input with label and error messages.
229
230 A `%Phoenix.HTML.Form{}` and field name may be passed to the input
231 to build input names and error messages, or all the attributes and
232 errors may be passed explicitly.
233
234 ## Examples
235
236 <.input field={@form[:email]} type="email" />
237 <.input name="my-input" errors={["oh no!"]} />
238 """
239 attr(:id, :any, default: nil)
240 attr(:name, :any)
241 attr(:label, :string, default: nil)
242 attr(:value, :any)
243
244 attr(:type, :string,
245 default: "text",
246 values: ~w(checkbox color date datetime-local email file hidden month number password
247 range radio search select tel text textarea time url week)
248 )
249
250 attr(:field, Phoenix.HTML.FormField,
251 doc: "a form field struct retrieved from the form, for example: @form[:email]"
252 )
253
254 attr(:errors, :list, default: [])
255 attr(:checked, :boolean, doc: "the checked flag for checkbox inputs")
256 attr(:prompt, :string, default: nil, doc: "the prompt for select inputs")
257 attr(:options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2")
258 attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs")
259
260 attr(:rest, :global,
261 include: ~w(autocomplete cols disabled form list max maxlength min minlength
262 pattern placeholder readonly required rows size step)
263 )
264
265 slot(:inner_block)
266
267 def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
268 assigns
269 147 |> assign(field: nil, id: assigns.id || field.id)
270 147 |> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
271 147 |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
272 132 |> assign_new(:value, fn -> field.value end)
273 147 |> input()
274 end
275
276 def input(%{type: "checkbox", value: value} = assigns) do
277 13 assigns =
278 13 assign_new(assigns, :checked, fn -> Phoenix.HTML.Form.normalize_value("checkbox", value) end)
279
280 13 ~H"""
281 13 <div phx-feedback-for={@name}>
282 <label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
283 13 <input type="hidden" name={@name} value="false" />
284 13 <input
285 type="checkbox"
286 13 id={@id}
287 13 name={@name}
288 value="true"
289 13 checked={@checked}
290 class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
291 13 {@rest}
292 />
293 7 <%= @label %>
294 </label>
295 13 <.error :for={msg <- @errors}><%= msg %></.error>
296 </div>
297 """
298 end
299
300 def input(%{type: "select"} = assigns) do
301 10 ~H"""
302 10 <div phx-feedback-for={@name}>
303 10 <.label for={@id}><%= @label %></.label>
304 10 <select
305 10 id={@id}
306 10 name={@name}
307 class="mt-1 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
308 10 multiple={@multiple}
309 10 {@rest}
310 >
311 10 <option :if={@prompt} value=""><%= @prompt %></option>
312 10 <%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
313 </select>
314 10 <.error :for={msg <- @errors}><%= msg %></.error>
315 </div>
316 """
317 end
318
319 def input(%{type: "textarea"} = assigns) do
320 25 ~H"""
321 25 <div phx-feedback-for={@name}>
322 25 <.label for={@id}><%= @label %></.label>
323 25 <textarea
324 25 id={@id}
325 25 name={@name}
326 class={[
327 "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
328 "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
329 "min-h-[6rem] border-zinc-300 focus:border-zinc-400",
330 25 @errors != [] && "border-rose-400 focus:border-rose-400"
331 ]}
332 25 {@rest}
333 25 ><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
334 25 <.error :for={msg <- @errors}><%= msg %></.error>
335 </div>
336 """
337 end
338
339 # All other inputs text, datetime-local, url, password, etc. are handled here...
340 def input(assigns) do
341 99 ~H"""
342 99 <div phx-feedback-for={@name}>
343 99 <.label for={@id}><%= @label %></.label>
344 55 <input
345 55 type={@type}
346 99 name={@name}
347 99 id={@id}
348 95 value={Phoenix.HTML.Form.normalize_value(@type, @value)}
349 class={[
350 "mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
351 "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
352 "border-zinc-300 focus:border-zinc-400",
353 99 @errors != [] && "border-rose-400 focus:border-rose-400"
354 ]}
355 99 {@rest}
356 />
357 99 <.error :for={msg <- @errors}><%= msg %></.error>
358 </div>
359 """
360 end
361
362 @doc """
363 Renders a label.
364 """
365 attr(:for, :string, default: nil)
366 slot(:inner_block, required: true)
367
368 def label(assigns) do
369 149 ~H"""
370 149 <label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
371 89 <%= render_slot(@inner_block) %>
372 </label>
373 """
374 end
375
376 @doc """
377 Generates a generic error message.
378 """
379 slot(:inner_block, required: true)
380
381 def error(assigns) do
382 29 ~H"""
383 <p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600 phx-no-feedback:hidden">
384 29 <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
385 29 <%= render_slot(@inner_block) %>
386 </p>
387 """
388 end
389
390 @doc """
391 Renders a header with title.
392 """
393 attr(:class, :string, default: nil)
394
395 slot(:inner_block, required: true)
396 slot(:subtitle)
397 slot(:actions)
398
399 def header(assigns) do
400 102 ~H"""
401 102 <header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
402 <div>
403 <h1 class="text-lg font-semibold leading-8 text-zinc-800">
404 101 <%= render_slot(@inner_block) %>
405 </h1>
406 98 <p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
407 20 <%= render_slot(@subtitle) %>
408 </p>
409 </div>
410 102 <div class="flex-none"><%= render_slot(@actions) %></div>
411 </header>
412 """
413 end
414
415 @doc ~S"""
416 Renders a table with generic styling.
417
418 ## Examples
419
420 <.table id="users" rows={@users}>
421 <:col :let={user} label="id"><%= user.id %></:col>
422 <:col :let={user} label="username"><%= user.username %></:col>
423 </.table>
424 """
425 attr(:id, :string, required: true)
426 attr(:rows, :list, required: true)
427 attr(:row_id, :any, default: nil, doc: "the function for generating the row id")
428 attr(:row_click, :any, default: nil, doc: "the function for handling phx-click on each row")
429
430 attr(:row_item, :any,
431 default: &Function.identity/1,
432 doc: "the function for mapping each row before calling the :col and :action slots"
433 )
434
435 slot :col, required: true do
436 attr(:label, :string)
437 attr(:class, :string, required: false)
438 end
439
440 slot(:action, doc: "the slot for showing user actions in the last table column")
441
442 def table(assigns) do
443 64 assigns =
444 0 with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
445 64 assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
446 end
447
448 64 ~H"""
449 <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
450 <table class="w-[40rem] mt-11 sm:w-full">
451 <thead class="text-sm text-left leading-6 text-zinc-500">
452 <tr>
453 52 <th :for={col <- @col} class="p-0 pr-6 pb-4 font-normal"><%= col[:label] %></th>
454 52 <th class="relative p-0 pb-4"><span class="sr-only"><%= gettext("Actions") %></span></th>
455 </tr>
456 </thead>
457 52 <tbody
458 52 id={@id}
459 64 phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
460 class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
461 >
462 <tr
463 64 :for={row <- @rows}
464 58 id={@row_id && @row_id.(row)}
465 class="group hover:bg-violet-50 hover:bg-opacity-25"
466 >
467 <td
468 58 :for={{col, i} <- Enum.with_index(@col)}
469 215 phx-click={@row_click && @row_click.(row)}
470 215 class={["relative p-0", @row_click && "hover:cursor-pointer", col[:class]]}
471 >
472 <div class="block py-4 pr-6">
473 <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-violet-50 group-hover:bg-opacity-25 sm:rounded-l-xl" />
474 215 <span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
475 215 <%= render_slot(col, @row_item.(row)) %>
476 </span>
477 </div>
478 </td>
479 58 <td :if={@action != []} class="relative w-14 p-0">
480 <div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
481 <span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-violet-50 group-hover:bg-opacity-25 sm:rounded-r-xl" />
482 <span
483 57 :for={action <- @action}
484 class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
485 >
486 114 <%= render_slot(action, @row_item.(row)) %>
487 </span>
488 </div>
489 </td>
490 </tr>
491 </tbody>
492 </table>
493 </div>
494 """
495 end
496
497 @doc """
498 Renders a data list.
499
500 ## Examples
501
502 <.list>
503 <:item title="Title"><%= @post.title %></:item>
504 <:item title="Views"><%= @post.views %></:item>
505 </.list>
506 """
507 slot :item, required: true do
508 attr(:title, :string, required: true)
509 end
510
511 def list(assigns) do
512 31 ~H"""
513 <div class="mt-14">
514 <dl class="-my-4 divide-y divide-zinc-100">
515 31 <div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
516 87 <dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
517 <dd class="text-zinc-700"><%= render_slot(item) %></dd>
518 </div>
519 </dl>
520 </div>
521 """
522 end
523
524 @doc """
525 Renders a back navigation link.
526
527 ## Examples
528
529 <.back navigate={~p"/posts"}>Back to posts</.back>
530 """
531 attr(:navigate, :any, required: true)
532 slot(:inner_block, required: true)
533
534 def back(assigns) do
535 28 ~H"""
536 <div class="mt-16">
537 28 <.link
538 28 navigate={@navigate}
539 class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
540 >
541 28 <.icon name="hero-arrow-left-solid" class="h-3 w-3" />
542 28 <%= render_slot(@inner_block) %>
543 </.link>
544 </div>
545 """
546 end
547
548 @doc """
549 Renders a [Hero Icon](https://heroicons.com).
550
551 Hero icons come in three styles – outline, solid, and mini.
552 By default, the outline style is used, but solid an mini may
553 be applied by using the `-solid` and `-mini` suffix.
554
555 You can customize the size and colors of the icons by setting
556 width, height, and background color classes.
557
558 Icons are extracted from your `assets/vendor/heroicons` directory and bundled
559 within your compiled app.css by the plugin in your `assets/tailwind.config.js`.
560
561 ## Examples
562
563 <.icon name="hero-x-mark-solid" />
564 <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
565 """
566 attr(:name, :string, required: true)
567 attr(:class, :string, default: nil)
568 attr(:rest, :global)
569
570 def icon(%{name: "hero-" <> _} = assigns) do
571 76 ~H"""
572 76 <span class={[@name, @class]} {@rest} />
573 """
574 end
575
576 ## JS Commands
577
578 def show(js \\ %JS{}, selector) do
579 19 JS.show(js,
580 to: selector,
581 transition:
582 {"transition-all transform ease-out duration-300",
583 "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
584 "opacity-100 translate-y-0 sm:scale-100"}
585 )
586 end
587
588 def hide(js \\ %JS{}, selector) do
589 76 JS.hide(js,
590 to: selector,
591 time: 200,
592 transition:
593 {"transition-all transform ease-in duration-200",
594 "opacity-100 translate-y-0 sm:scale-100",
595 "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
596 )
597 end
598
599 def show_modal(js \\ %JS{}, id) when is_binary(id) do
600 js
601 19 |> JS.show(to: "##{id}")
602 |> JS.show(
603 19 to: "##{id}-bg",
604 transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
605 )
606 19 |> show("##{id}-container")
607 |> JS.add_class("overflow-hidden", to: "body")
608 19 |> JS.focus_first(to: "##{id}-content")
609 end
610
611 def hide_modal(js \\ %JS{}, id) do
612 js
613 |> JS.hide(
614 19 to: "##{id}-bg",
615 transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
616 )
617 19 |> hide("##{id}-container")
618 19 |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
619 |> JS.remove_class("overflow-hidden", to: "body")
620 19 |> JS.pop_focus()
621 end
622
623 @doc """
624 Translates an error message using gettext.
625 """
626 def translate_error({msg, opts}) do
627 # When using gettext, we typically pass the strings we want
628 # to translate as a static argument:
629 #
630 # # Translate the number of files with plural rules
631 # dngettext("errors", "1 file", "%{count} files", count)
632 #
633 # However the error messages in our forms and APIs are generated
634 # dynamically, so we need to translate them by calling Gettext
635 # with our gettext backend as first argument. Translations are
636 # available in the errors.po file (as we use the "errors" domain).
637 29 if count = opts[:count] do
638 0 Gettext.dngettext(KlepsidraWeb.Gettext, "errors", msg, msg, count, opts)
639 else
640 29 Gettext.dgettext(KlepsidraWeb.Gettext, "errors", msg, opts)
641 end
642 end
643
644 @doc """
645 Translates the errors for a field from a keyword list of errors.
646 """
647 def translate_errors(errors, field) when is_list(errors) do
648 0 for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
649 end
650
651 def live_select(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
652 15 assigns =
653 assigns
654 15 |> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
655 |> assign(:live_select_opts, assigns_to_attributes(assigns, [:errors, :label]))
656
657 15 ~H"""
658 15 <div phx-feedback-for={@field.name}>
659 15 <.label for={@field.id}><%= @label %></.label>
660 15 <LiveSelect.live_select
661 15 field={@field}
662 active_option_class={["bg-violet-700 text-white"]}
663 available_option_class={["cursor-pointer hover:bg-violet-500 hover:text-white rounded"]}
664 clear_button_class={["cursor-pointer hidden"]}
665 option_class={["px-4 py-1 rounded"]}
666 selected_option_class={["bg-violet-400"]}
667 text_input_class={[
668 "mt-2 block w-full rounded-lg border-zinc-300 py-[7px] px-[11px]",
669 "text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6",
670 "phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5",
671 "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5",
672 15 @errors != [] && "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10"
673 ]}
674 15 {@live_select_opts}
675 />
676
677 15 <.error :for={msg <- @errors}><%= msg %></.error>
678 </div>
679 """
680 end
681 end

lib/klepsidra_web/components/layouts.ex

0.0
0
0
0
Line Hits Source
0 defmodule KlepsidraWeb.Layouts do
1 @moduledoc false
2
3 use KlepsidraWeb, :html
4
5 # Changed from embed_templates "layouts/*" for web LiveView only
6 embed_templates "layouts/*.html"
7 end

lib/klepsidra_web/controllers/error_html.ex

100.0
1
2
0
Line Hits Source
0 defmodule KlepsidraWeb.ErrorHTML do
1 @moduledoc false
2
3 use KlepsidraWeb, :html
4
5 # If you want to customize your error pages,
6 # uncomment the embed_templates/1 call below
7 # and add pages to the error directory:
8 #
9 # * lib/klepsidra_web/controllers/error_html/404.html.heex
10 # * lib/klepsidra_web/controllers/error_html/500.html.heex
11 #
12 # embed_templates "error_html/*"
13
14 # The default is to render a plain text page based on
15 # the template name. For example, "404.html" becomes
16 # "Not Found".
17 def render(template, _assigns) do
18 2 Phoenix.Controller.status_message_from_template(template)
19 end
20 end

lib/klepsidra_web/controllers/error_json.ex

100.0
1
2
0
Line Hits Source
0 defmodule KlepsidraWeb.ErrorJSON do
1 @moduledoc false
2
3 # If you want to customize a particular status code,
4 # you may add your own clauses, such as:
5 #
6 # def render("500.json", _assigns) do
7 # %{errors: %{detail: "Internal Server Error"}}
8 # end
9
10 # By default, Phoenix returns the status message from
11 # the template name. For example, "404.json" becomes
12 # "Not Found".
13 def render(template, _assigns) do
14 2 %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
15 end
16 end

lib/klepsidra_web/controllers/page_html.ex

0.0
0
0
0
Line Hits Source
0 defmodule KlepsidraWeb.PageHTML do
1 @moduledoc false
2
3 use KlepsidraWeb, :html
4
5 embed_templates "page_html/*"
6 end

lib/klepsidra_web/endpoint.ex

0.0
0
0
0
Line Hits Source
0 defmodule KlepsidraWeb.Endpoint do
1 @moduledoc false
2
3 use Phoenix.Endpoint, otp_app: :klepsidra
4
5 # The session will be stored in the cookie and signed,
6 # this means its contents can be read but not tampered with.
7 # Set :encryption_salt if you would also like to encrypt it.
8 @session_options [
9 store: :cookie,
10 key: "_klepsidra_key",
11 signing_salt: "tTGt4N0Z",
12 same_site: "Lax"
13 ]
14
15 socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
16
17 # Serve at "/" the static files from "priv/static" directory.
18 #
19 # You should set gzip to true if you are running phx.digest
20 # when deploying your static files in production.
21 plug Plug.Static,
22 at: "/",
23 from: :klepsidra,
24 gzip: false,
25 only: KlepsidraWeb.static_paths()
26
27 # Code reloading can be explicitly enabled under the
28 # :code_reloader configuration of your endpoint.
29 if code_reloading? do
30 socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
31 plug Phoenix.LiveReloader
32 plug Phoenix.CodeReloader
33 plug Phoenix.Ecto.CheckRepoStatus, otp_app: :klepsidra
34 end
35
36 plug Phoenix.LiveDashboard.RequestLogger,
37 param_key: "request_logger",
38 cookie_key: "request_logger"
39
40 plug Plug.RequestId
41 plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
42
43 plug Plug.Parsers,
44 parsers: [:urlencoded, :multipart, :json],
45 pass: ["*/*"],
46 json_decoder: Phoenix.json_library()
47
48 plug Plug.MethodOverride
49 plug Plug.Head
50 plug Plug.Session, @session_options
51 plug KlepsidraWeb.Router
52 end

lib/klepsidra_web/gettext.ex

0.0
0
0
0
Line Hits Source
0 defmodule KlepsidraWeb.Gettext do
1 @moduledoc """
2 A module providing Internationalization with a gettext-based API.
3
4 By using [Gettext](https://hexdocs.pm/gettext),
5 your module gains a set of macros for translations, for example:
6
7 import KlepsidraWeb.Gettext
8
9 # Simple translation
10 gettext("Here is the string to translate")
11
12 # Plural translation
13 ngettext("Here is the string to translate",
14 "Here are the strings to translate",
15 3)
16
17 # Domain-based translation
18 dgettext("errors", "Here is the error message to translate")
19
20 See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
21 """
22 use Gettext.Backend, otp_app: :klepsidra
23 end

lib/klepsidra_web/klepsidra_csp.ex

75.0
20
2574
5
Line Hits Source
0 defmodule KlepsidraWeb.CSP do
1 @moduledoc """
2 Module that includes Plug and LiveView helpers to handle Content Security
3 Policy header.
4
5 For inline `<style>` and `<script>` tags, nonce should be used. When the REST
6 request is processed a nonce is added to the process dictionary. This ensures
7 the nonce stays the same throughout the call, as the nonce in the tags must
8 match the nonce in the header.
9
10 To allow for inline `<style>` and/or `<script>` tag you must set a `'nonce'`
11 source.
12
13 ## Set up
14
15 To set up MyAppWeb.CSP in your app you must:
16
17 ### 1) Configure `lib/my_app_web.ex`
18
19 Ensure you import the helpers in `MyAppWeb`.
20
21 def router do
22 quote do
23 use Phoenix.Router, helpers: false
24
25 # Import common connection and controller functions to use in pipelines
26 import Plug.Conn
27 import Phoenix.Controller
28 import Phoenix.LiveView.Router
29
30 import MyAppWeb.CSP, only: [put_content_security_policy: 2]
31 end
32 end
33
34 # ...
35
36 def html do
37 quote do
38 use Phoenix.Component
39
40 import MyAppWeb.CldrHelpers
41
42 # Import convenience functions from controllers
43 import Phoenix.Controller,
44 only: [get_csrf_token: 0, view_module: 1, view_template: 1]
45
46 import MyAppWeb.CSP,
47 only: [get_csp_nonce: 0]
48
49 # Include general helpers for rendering HTML
50 unquote(html_helpers())
51 end
52 end
53
54 # ...
55
56 def live_view do
57 quote do
58 use Phoenix.LiveView,
59 layout: {MyAppWeb.Layouts, :app}
60
61 on_mount MyAppWeb.CSP
62
63 unquote(html_helpers())
64 end
65 end
66
67 ### 2) Add nonce metatag to the HTML document
68
69 Add the following metatag head to
70 `lib/my_app_web/components/layouts/root.html.heex`.
71
72 <meta name="csp-nonce" content={get_csp_nonce()} />
73
74 ### 3) Pass the CSP nonce to the LiveView socket
75
76 Ensure you pass on the CSP nonce to the LiveView socket in
77 `assets/js/app.js`.
78
79 let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
80 let cspNonce = document.querySelector("meta[name='csp-nonce']").getAttribute("content")
81 let liveSocket = new LiveSocket("/live", Socket, {
82 longPollFallbackMs: 2500,
83 params: { _csrf_token: csrfToken, _csp_nonce: cspNonce }
84 })
85
86 ## Usage
87
88 If you got inline `<style>` or script tags you must set the nonce attribute:
89
90 <style nonce={get_csp_nonce()}>
91 // ...
92 </style>
93 """
94 require Logger
95
96 import Plug.Conn
97
98 @doc """
99 Sets a content security policy header.
100
101 By default the policy is `default-src 'self'`. `'nonce'` source will be
102 expanded with an auto-generated nonce that is persisted in the process
103 dictionary.
104
105 The options can be a function or a keyword list. Sources can be a binary
106 or list of binaries. Duplicate directives will be merged together.
107
108 ## Example
109
110 plug :put_content_security_policy,
111 img_src: "'self' data:`,
112 style_src: "'self' 'nonce'"
113
114 plug :put_content_security_policy,
115 img_src: [
116 "'self'",
117 "data:"
118 ]
119
120 plug :put_content_security_policy, &MyAppWeb.CSPPolicy.opts/1
121 """
122 def put_content_security_policy(conn, fun) when is_function(fun, 1) do
123 0 put_content_security_policy(conn, fun.(conn))
124 end
125
126 def put_content_security_policy(conn, opts) when is_list(opts) do
127 39 csp =
128 opts
129 |> Keyword.has_key?(:default_src)
130 |> case do
131 0 false -> [default_src: "'self'"] ++ opts
132 39 true -> opts
133 end
134 |> Enum.reduce([], fn {name, sources}, acc ->
135 273 sources = List.wrap(sources)
136
137 273 Keyword.update(acc, name, sources, &(&1 ++ sources))
138 end)
139 |> Enum.reduce("", fn {name, sources}, acc ->
140 273 name = String.replace(to_string(name), "_", "-")
141
142 273 sources =
143 sources
144 |> Enum.uniq()
145 |> Enum.join(" ")
146 273 |> String.replace("'nonce'", "'nonce-#{get_csp_nonce()}'")
147
148 273 "#{acc}#{name} #{sources};"
149 end)
150
151 39 put_resp_header(conn, "content-security-policy", csp)
152 end
153
154 @doc """
155 Gets the CSP nonce.
156
157 Generates a nonce and stores it in the process dictionary if one does not exist.
158 """
159 def get_csp_nonce do
160 351 if nonce = Process.get(:plug_csp_nonce) do
161 312 nonce
162 else
163 39 nonce = csp_nonce()
164 39 Process.put(:plug_csp_nonce, nonce)
165 39 nonce
166 end
167 end
168
169 defp csp_nonce do
170 24
171 |> :crypto.strong_rand_bytes()
172 39 |> Base.encode64(padding: false)
173 end
174
175 @doc """
176 Loads the CSP nonce into the LiveView process.
177 """
178 def on_mount(
179 :default,
180 _params,
181 _session,
182 %{private: %{connect_params: %{"_csp_nonce" => nonce}}} = socket
183 ) do
184 0 Process.put(:plug_csp_nonce, nonce)
185
186 {:cont, socket}
187 end
188
189 def on_mount(:default, _params, _session, socket) do
190 0 unless Process.get(:plug_csp_nonce) do
191 0 Logger.debug("""
192 LiveView session was misconfigured.
193
194 1) Ensure the `put_content_security_policy` plug is in your router pipeline:
195
196 plug :put_content_security_policy
197
198 2) Define the CSRF meta tag inside the `<head>` tag in your layout:
199
200 <meta name="csp-nonce" content={MyAppWeb.CSP.get_csp_nonce()} />
201
202 3) Pass it forward in your app.js:
203
204 let csrfToken = document.querySelector("meta[name='csp-nonce']").getAttribute("content");
205 let liveSocket = new LiveSocket("/live", Socket, {params: {_csp_nonce: cspNonce}});
206 """)
207 end
208
209 {:cont, socket}
210 end
211 end

lib/klepsidra_web/live/activity_type_live/form_component.ex

86.2
29
106
4
Line Hits Source
0 defmodule KlepsidraWeb.ActivityTypeLive.FormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4 import LiveToast
5 alias Klepsidra.TimeTracking
6
7 @impl true
8 def render(assigns) do
9 7 ~H"""
10 <div>
11 3 <.header>
12 3 <%= @title %>
13 </.header>
14
15 7 <.simple_form
16 7 for={@form}
17 id="activity_type-form"
18 7 phx-target={@myself}
19 phx-change="validate"
20 phx-submit="save"
21 >
22 7 <.input field={@form[:name]} type="text" label="Activity type" />
23
24 7 <.input field={@form[:billing_rate]} type="number" label="Billing rate" min="0" step="0.01" />
25
26 7 <.input field={@form[:active]} type="checkbox" label="Active" />
27 7 <:actions>
28 7 <.button phx-disable-with="Saving...">Save</.button>
29 </:actions>
30 </.simple_form>
31 </div>
32 """
33 end
34
35 @impl true
36 3 def update(%{activity_type: activity_type} = assigns, socket) do
37 3 changeset = TimeTracking.change_activity_type(activity_type)
38
39 {:ok,
40 socket
41 |> assign(assigns)
42 |> assign_form(changeset)}
43 end
44
45 @impl true
46 3 def handle_event("validate", %{"activity_type" => activity_type_params}, socket) do
47 3 changeset =
48 3 socket.assigns.activity_type
49 |> TimeTracking.change_activity_type(activity_type_params)
50 |> Map.put(:action, :validate)
51
52 {:noreply, assign_form(socket, changeset)}
53 end
54
55 def handle_event("save", %{"activity_type" => activity_type_params}, socket) do
56 3 save_activity_type(socket, socket.assigns.action, activity_type_params)
57 end
58
59 2 defp save_activity_type(socket, :edit, activity_type_params) do
60 2 case TimeTracking.update_activity_type(socket.assigns.activity_type, activity_type_params) do
61 {:ok, activity_type} ->
62 2 notify_parent({:saved, activity_type})
63
64 {:noreply,
65 socket
66 |> put_toast(:info, "Activity type updated successfully")
67 2 |> push_patch(to: socket.assigns.patch)}
68
69 0 {:error, %Ecto.Changeset{} = changeset} ->
70 {:noreply, assign_form(socket, changeset)}
71 end
72 end
73
74 0 defp save_activity_type(socket, :new, activity_type_params) do
75 1 case TimeTracking.create_activity_type(activity_type_params) do
76 {:ok, activity_type} ->
77 0 notify_parent({:saved, activity_type})
78
79 {:noreply,
80 socket
81 |> put_toast(:info, "Activity type created successfully")
82 0 |> push_patch(to: socket.assigns.patch)}
83
84 1 {:error, %Ecto.Changeset{} = changeset} ->
85 {:noreply, assign_form(socket, changeset)}
86 end
87 end
88
89 defp assign_form(socket, %Ecto.Changeset{} = changeset) do
90 7 assign(socket, :form, to_form(changeset))
91 end
92
93 2 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
94 end

lib/klepsidra_web/live/activity_type_live/index.ex

100.0
11
46
0
Line Hits Source
0 defmodule KlepsidraWeb.ActivityTypeLive.Index do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4 import LiveToast
5
6 alias Klepsidra.TimeTracking
7 alias Klepsidra.TimeTracking.ActivityType
8
9 @impl true
10 8 def mount(_params, _session, socket) do
11 {:ok, stream(socket, :activity_types, TimeTracking.list_activity_types())}
12 end
13
14 @impl true
15 11 def handle_params(params, _url, socket) do
16 11 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
17 end
18
19 defp apply_action(socket, :edit, %{"id" => id}) do
20 socket
21 |> assign(:page_title, "Edit activity type")
22 1 |> assign(:activity_type, TimeTracking.get_activity_type!(id))
23 end
24
25 defp apply_action(socket, :new, _params) do
26 socket
27 |> assign(:page_title, "New activity type")
28 1 |> assign(:activity_type, %ActivityType{})
29 end
30
31 defp apply_action(socket, :index, _params) do
32 socket
33 |> assign(:page_title, "Activity types")
34 9 |> assign(:activity_type, nil)
35 end
36
37 @impl true
38 1 def handle_info({KlepsidraWeb.ActivityTypeLive.FormComponent, {:saved, activity_type}}, socket) do
39 {:noreply, stream_insert(socket, :activity_types, activity_type)}
40 end
41
42 @impl true
43 1 def handle_event("delete", %{"id" => id}, socket) do
44 1 activity_type = TimeTracking.get_activity_type!(id)
45 1 {:ok, _} = TimeTracking.delete_activity_type(activity_type)
46
47 {:noreply, handle_deleted_activity_type(socket, activity_type, :activity_types)}
48 end
49
50 defp handle_deleted_activity_type(socket, activity_type, source_stream) do
51 socket
52 |> stream_delete(source_stream, activity_type)
53 1 |> put_toast(:info, "Activity type deleted successfully")
54 end
55 end

lib/klepsidra_web/live/activity_type_live/show.ex

100.0
5
22
0
Line Hits Source
0 defmodule KlepsidraWeb.ActivityTypeLive.Show do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.TimeTracking
6
7 @impl true
8 4 def mount(_params, _session, socket) do
9 {:ok, socket}
10 end
11
12 @impl true
13 6 def handle_params(%{"id" => id}, _, socket) do
14 {:noreply,
15 socket
16 6 |> assign(:page_title, page_title(socket.assigns.live_action))
17 |> assign(:activity_type, TimeTracking.get_activity_type!(id))}
18 end
19
20 5 defp page_title(:show), do: "Show Activity type"
21 1 defp page_title(:edit), do: "Edit Activity type"
22 end

lib/klepsidra_web/live/business_partner_live/form_component.ex

86.1
36
112
5
Line Hits Source
0 defmodule KlepsidraWeb.BusinessPartnerLive.FormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4 import LiveToast
5 alias Klepsidra.BusinessPartners
6
7 @impl true
8 def render(assigns) do
9 7 ~H"""
10 <div>
11 3 <.header>
12 3 <%= @title %>
13 </.header>
14
15 7 <.simple_form
16 7 for={@form}
17 id="business_partner-form"
18 7 phx-target={@myself}
19 phx-change="validate"
20 phx-submit="save"
21 >
22 7 <.input field={@form[:name]} type="text" label="Name" />
23 7 <.input field={@form[:description]} type="textarea" label="Description" />
24 7 <:actions>
25 7 <.button phx-disable-with="Saving...">Save</.button>
26 </:actions>
27 </.simple_form>
28 </div>
29 """
30 end
31
32 @impl true
33 3 def update(%{business_partner: business_partner} = assigns, socket) do
34 3 changeset = BusinessPartners.change_business_partner(business_partner)
35
36 {:ok,
37 socket
38 |> assign(assigns)
39 |> assign_form(changeset)}
40 end
41
42 @impl true
43 3 def handle_event("validate", %{"business_partner" => business_partner_params}, socket) do
44 3 changeset =
45 3 socket.assigns.business_partner
46 |> BusinessPartners.change_business_partner(business_partner_params)
47 |> Map.put(:action, :validate)
48
49 {:noreply, assign_form(socket, changeset)}
50 end
51
52 def handle_event("save", %{"business_partner" => business_partner_params}, socket) do
53 3 business_partner_params = Map.merge(business_partner_params, %{"customer" => "true"})
54
55 3 save_business_partner(socket, socket.assigns.action, business_partner_params)
56 end
57
58 2 defp save_business_partner(socket, :edit, business_partner_params) do
59 2 business_partner_type =
60 2 if socket.assigns.business_partner_type == :customer, do: "Customer", else: "Supplier"
61
62 2 case BusinessPartners.update_business_partner(
63 2 socket.assigns.business_partner,
64 business_partner_params
65 ) do
66 {:ok, business_partner} ->
67 2 notify_parent({:saved, business_partner})
68
69 {:noreply,
70 socket
71 2 |> put_toast(:info, "#{business_partner_type} updated successfully")
72 2 |> push_patch(to: socket.assigns.patch)}
73
74 0 {:error, %Ecto.Changeset{} = changeset} ->
75 {:noreply, assign_form(socket, changeset)}
76 end
77 end
78
79 0 defp save_business_partner(socket, :new, business_partner_params) do
80 1 business_partner_type =
81 1 if socket.assigns.business_partner_type == :customer, do: "Customer", else: "Supplier"
82
83 1 case BusinessPartners.create_business_partner(business_partner_params) do
84 {:ok, business_partner} ->
85 0 notify_parent({:saved, business_partner})
86
87 {:noreply,
88 socket
89 0 |> put_flash(:info, "#{business_partner_type} created successfully")
90 0 |> push_patch(to: socket.assigns.patch)}
91
92 1 {:error, %Ecto.Changeset{} = changeset} ->
93 {:noreply, assign_form(socket, changeset)}
94 end
95 end
96
97 defp assign_form(socket, %Ecto.Changeset{} = changeset) do
98 7 assign(socket, :form, to_form(changeset))
99 end
100
101 2 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
102 end

lib/klepsidra_web/live/business_partner_live/index.ex

100.0
12
54
0
Line Hits Source
0 defmodule KlepsidraWeb.BusinessPartnerLive.Index do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4 import LiveToast
5
6 alias Klepsidra.BusinessPartners
7 alias Klepsidra.BusinessPartners.BusinessPartner
8
9 @impl true
10 8 def mount(_params, _session, socket) do
11 8 socket =
12 socket
13 |> assign(:business_partner_type, :customer)
14 |> stream(:business_partners, BusinessPartners.list_business_partners())
15
16 {:ok, socket}
17 end
18
19 @impl true
20 11 def handle_params(params, _url, socket) do
21 11 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
22 end
23
24 defp apply_action(socket, :edit, %{"id" => id}) do
25 socket
26 |> assign(:page_title, "Edit customer details")
27 1 |> assign(:business_partner, BusinessPartners.get_business_partner!(id))
28 end
29
30 defp apply_action(socket, :new, _params) do
31 socket
32 |> assign(:page_title, "New customer")
33 1 |> assign(:business_partner, %BusinessPartner{})
34 end
35
36 defp apply_action(socket, :index, _params) do
37 socket
38 |> assign(:page_title, "Customers")
39 9 |> assign(:business_partner, nil)
40 end
41
42 @impl true
43 1 def handle_info(
44 {KlepsidraWeb.BusinessPartnerLive.FormComponent, {:saved, business_partner}},
45 socket
46 ) do
47 {:noreply, stream_insert(socket, :business_partners, business_partner)}
48 end
49
50 @impl true
51 1 def handle_event("delete", %{"id" => id}, socket) do
52 1 business_partner = BusinessPartners.get_business_partner!(id)
53 1 {:ok, _} = BusinessPartners.delete_business_partner(business_partner)
54
55 {:noreply, handle_deleted_customer(socket, business_partner, :business_partners)}
56 end
57
58 defp handle_deleted_customer(socket, customer, source_stream) do
59 socket
60 |> stream_delete(source_stream, customer)
61 1 |> put_toast(:info, "Customer deleted successfully")
62 end
63 end

lib/klepsidra_web/live/business_partner_live/show.ex

100.0
5
22
0
Line Hits Source
0 defmodule KlepsidraWeb.BusinessPartnerLive.Show do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.BusinessPartners
6
7 @impl true
8 4 def mount(_params, _session, socket) do
9 {:ok, assign(socket, :business_partner_type, :customer)}
10 end
11
12 @impl true
13 6 def handle_params(%{"id" => id}, _, socket) do
14 {:noreply,
15 socket
16 6 |> assign(:page_title, page_title(socket.assigns.live_action))
17 |> assign(:business_partner, BusinessPartners.get_business_partner!(id))}
18 end
19
20 5 defp page_title(:show), do: "Show customer"
21 1 defp page_title(:edit), do: "Edit customer"
22 end

lib/klepsidra_web/live/journal_entry_live/form_component.ex

0.0
109
0
109
Line Hits Source
0 defmodule KlepsidraWeb.JournalEntryLive.FormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4
5 alias Klepsidra.Journals
6 alias Klepsidra.Journals.JournalEntryTypes
7 alias Klepsidra.Locations.City
8 alias Klepsidra.Categorisation
9 alias Klepsidra.Categorisation.Tag
10 alias KlepsidraWeb.TagLive.TagUtilities
11 alias Klepsidra.DynamicCSS
12
13 @tag_search_live_component_id "journal_entry_ls_tag_search_live_select_component"
14
15 @impl true
16 def render(assigns) do
17 0 ~H"""
18 <div>
19 0 <.header>
20 0 <%= @title %>
21 0 <:subtitle :if={@action == :new}>What did you do today?</:subtitle>
22 </.header>
23
24 0 <.simple_form
25 0 for={@form}
26 id="journal_entry-form"
27 0 phx-target={@myself}
28 phx-change="validate"
29 phx-window-keyup="key_up"
30 phx-submit="save"
31 >
32 0 <.input
33 0 :if={@action == :new}
34 0 field={@form[:journal_for]}
35 type="date"
36 label="Journal for"
37 0 value={@datestamp}
38 />
39 0 <.input :if={@action == :edit} field={@form[:journal_for]} type="date" label="Journal for" />
40 0 <.input field={@form[:entry_type_id]} type="select" label="Entry type" options={@entry_types} />
41 0 <.input
42 0 field={@form[:entry_text_markdown]}
43 type="textarea"
44 label="Journal entry"
45 phx-debounce="1500"
46 />
47 0 <.input
48 0 field={@form[:highlights]}
49 type="text"
50 label="Key takeaways or highlights"
51 placeholder="Summary of key points"
52 />
53
54 0 <div id="tag-selector" class={"flex #{if @selected_tag_queue != [], do: "gap-2"}"}>
55 0 <div
56 id="tag-selector__live-select"
57 phx-mounted={JS.add_class("hidden", to: "#journal_entry_ls_tag_search_text_input")}
58 >
59 0 <.live_select
60 0 field={@form[:ls_tag_search]}
61 mode={:tags}
62 label=""
63 options={[]}
64 placeholder="Add tag"
65 debounce={80}
66 clear_tag_button_class="cursor-pointer px-1 rounded-r-md"
67 dropdown_extra_class="bg-white max-h-48 overflow-y-scroll"
68 tag_class="bg-slate-400 text-white flex rounded-md text-sm font-semibold"
69 tags_container_class="flex flex-wrap gap-2"
70 container_extra_class="rounded border border-none"
71 update_min_len={1}
72 user_defined_options="true"
73 0 value={@selected_tags}
74 phx-blur="ls_tag_search_blur"
75 0 phx-target={@myself}
76 >
77 0 <:option :let={option}>
78 0 <div class="flex" title={option.description}>
79 0 <%= option.label %>
80 </div>
81 </:option>
82 0 <:tag :let={option}>
83 0 <div class={"#{option.tag_class} py-1.5 px-3 rounded-l-md"} title={option.description}>
84 0 <.link navigate={~p"/tags/#{option.value}"}>
85 0 <%= option.label %>
86 </.link>
87 </div>
88 </:tag>
89 </.live_select>
90 </div>
91
92 <div
93 id="tag-selector__colour-select"
94 class="tag-colour-picker hidden w-10 overflow-hidden self-end shrink-0"
95 >
96 0 <.input field={@form[:bg_colour]} type="color" value={elem(@new_tag_colour, 0)} />
97 </div>
98
99 0 <.button
100 id="tag-selector__add-button"
101 class="add-tag-button flex-none flex-grow-0 h-fit self-end [&&]:bg-violet-50 [&&]:text-indigo-900 [&&]:py-1 rounded-md"
102 type="button"
103 phx-click={enable_tag_selector()}
104 >
105 Add tag +
106 </.button>
107 </div>
108
109 0 <.input field={@form[:mood]} type="text" label="How would you describe your mood?" />
110 0 <.live_select
111 0 field={@form[:location_id]}
112 mode={:single}
113 label="Location"
114 options={[]}
115 placeholder="Where are you?"
116 debounce={200}
117 dropdown_extra_class="bg-white max-h-48 overflow-y-scroll"
118 update_min_len={2}
119 value_mapper={&value_mapper/1}
120 phx-focus="location_focus"
121 phx-blur="location_blur"
122 0 phx-target={@myself}
123 >
124 0 <:option :let={option}>
125 <div class="flex">
126 0 <%= option.label %>
127 </div>
128 </:option>
129 </.live_select>
130
131 0 <.input field={@form[:is_private]} type="checkbox" label="Private entry?" />
132 0 <:actions>
133 0 <.button phx-disable-with="Saving...">Save journal entry</.button>
134 </:actions>
135 </.simple_form>
136 </div>
137 """
138 end
139
140 @impl true
141 0 def update(%{journal_entry: journal_entry} = assigns, socket) do
142 0 journal_entry = journal_entry |> Klepsidra.Repo.preload(:tags)
143
144 0 socket =
145 socket
146 |> TagUtilities.generate_tag_options(
147 [],
148 0 Enum.map(journal_entry.tags, fn tag -> tag.id end),
149 @tag_search_live_component_id
150 )
151 |> Phx.Live.Head.push(
152 "style[id*=dynamic-style-block]",
153 :dynamic,
154 "style_declarations",
155 0 DynamicCSS.generate_tag_styles(journal_entry.tags)
156 )
157 |> assign(assigns)
158 |> assign_new(:form, fn ->
159 0 to_form(Journals.change_journal_entry(journal_entry))
160 end)
161 |> assign(new_tag_colour: {"#94a3b8", "#fff"})
162 |> assign_entry_type()
163
164 {:ok, socket}
165 end
166
167 @impl true
168 0 def handle_event(
169 "validate",
170 %{
171 "_target" => ["journal_entry", "ls_tag_search"],
172 "journal_entry" => %{"ls_tag_search" => tags_applied}
173 },
174 socket
175 ) do
176 0 Tag.handle_tag_list_changes(
177 0 socket.assigns.selected_tag_queue,
178 tags_applied,
179 0 socket.assigns.journal_entry.id,
180 &Categorisation.add_journal_entry_tag(&1, &2),
181 &Categorisation.delete_journal_entry_tag(&1, &2)
182 )
183
184 0 socket =
185 TagUtilities.generate_tag_options(
186 socket,
187 0 socket.assigns.selected_tag_queue,
188 tags_applied,
189 @tag_search_live_component_id,
190 0 parent_tag_select_id: socket.assigns.parent_tag_select_id
191 )
192 |> Phx.Live.Head.push(
193 "style[id*=dynamic-style-block]",
194 :dynamic,
195 "style_declarations",
196 DynamicCSS.generate_tag_styles(tags_applied)
197 )
198 |> assign(
199 tag_search_phrase: nil,
200 possible_free_tag_entered: false
201 )
202
203 {:noreply, socket}
204 end
205
206 @doc """
207 Validate event which fires only once the last of the tags has been cleared
208 from a `live_select` component.
209 """
210 0 def handle_event(
211 "validate",
212 %{
213 "_target" => ["journal_entry", "ls_tag_search_empty_selection"],
214 "journal_entry" => %{"ls_tag_search_empty_selection" => ""}
215 },
216 socket
217 ) do
218 0 Tag.handle_tag_list_changes(
219 0 socket.assigns.selected_tag_queue,
220 [],
221 0 socket.assigns.journal_entry.id,
222 &Categorisation.add_journal_entry_tag(&1, &2),
223 &Categorisation.delete_journal_entry_tag(&1, &2)
224 )
225
226 0 socket.assigns.parent_tag_select_id &&
227 0 send_update(LiveSelect.Component, id: socket.assigns.parent_tag_select_id, value: [])
228
229 0 socket =
230 socket
231 |> assign(
232 tag_search_phrase: nil,
233 possible_free_tag_entered: false
234 )
235
236 {:noreply, socket}
237 end
238
239 0 def handle_event(
240 "validate",
241 %{
242 "_target" => ["journal_entry", "bg_colour"],
243 "journal_entry" => %{
244 "bg_colour" => bg_colour
245 }
246 },
247 socket
248 ) do
249 0 fg_colour =
250 case ColorContrast.calc_contrast(bg_colour) do
251 0 {:ok, fg_colour} -> fg_colour
252 0 {:error, _} -> "#fff"
253 end
254
255 0 socket =
256 socket
257 |> assign(new_tag_colour: {bg_colour, fg_colour})
258
259 {:noreply, socket}
260 end
261
262 0 def handle_event("validate", %{"journal_entry" => journal_entry_params}, socket) do
263 0 changeset =
264 0 socket.assigns.journal_entry
265 |> Journals.change_journal_entry(journal_entry_params)
266
267 {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
268 end
269
270 def handle_event("save", %{"journal_entry" => journal_entry_params}, socket) do
271 0 save_journal_entry(socket, socket.assigns.action, journal_entry_params)
272 end
273
274 @impl true
275 0 def handle_event(
276 "live_select_change",
277 %{
278 "field" => "journal_entry_ls_tag_search",
279 "id" => live_select_id,
280 "text" => tag_search_phrase
281 },
282 socket
283 ) do
284 0 tag_search_results =
285 Categorisation.search_tags_by_name_content(tag_search_phrase)
286 |> TagUtilities.tag_options_for_live_select()
287
288 0 send_update(LiveSelect.Component, id: live_select_id, options: tag_search_results)
289
290 0 socket =
291 socket
292 |> assign(
293 tag_search_phrase: tag_search_phrase,
294 possible_free_tag_entered: true
295 )
296
297 {:noreply, socket}
298 end
299
300 0 def handle_event(
301 "ls_tag_search_blur",
302 %{"id" => @tag_search_live_component_id},
303 socket
304 ) do
305 0 socket =
306 socket
307 |> assign(
308 tag_search_phrase: nil,
309 possible_free_tag_entered: false
310 )
311
312 {:noreply, socket}
313 end
314
315 0 def handle_event(
316 "key_up",
317 %{"key" => "Enter"},
318 %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} =
319 socket
320 ) do
321 0 socket =
322 TagUtilities.handle_free_tagging(
323 socket,
324 tag_search_phrase,
325 String.length(tag_search_phrase),
326 @tag_search_live_component_id,
327 0 socket.assigns.new_tag_colour
328 )
329
330 {:noreply, socket}
331 end
332
333 0 def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket}
334
335 0 def handle_event(
336 "live_select_change",
337 %{
338 "field" => "journal_entry_location_id",
339 "id" => live_select_id,
340 "text" => text
341 },
342 socket
343 ) do
344 0 cities = Klepsidra.Locations.city_search(text)
345
346 0 send_update(LiveSelect.Component, id: live_select_id, options: cities)
347
348 {:noreply, socket}
349 end
350
351 0 def handle_event("focus", _params, socket) do
352 {:noreply, socket}
353 end
354
355 0 def handle_event("clear", %{"id" => id}, socket) do
356 0 send_update(LiveSelect.Component, options: [], id: id)
357
358 {:noreply, socket}
359 end
360
361 0 def handle_event("location_focus", %{"id" => _id}, socket) do
362 {:noreply, socket}
363 end
364
365 0 def handle_event("location_blur", %{"id" => _id}, socket) do
366 {:noreply, socket}
367 end
368
369 0 defp save_journal_entry(socket, :edit, journal_entry_params) do
370 0 case Journals.update_journal_entry(socket.assigns.journal_entry, journal_entry_params) do
371 {:ok, journal_entry} ->
372 0 journal_entry =
373 [journal_entry | []]
374 |> Klepsidra.Journals.preload_journal_entry_type()
375 |> List.first()
376
377 0 notify_parent({:saved, journal_entry})
378
379 {:noreply,
380 socket
381 |> put_flash(:info, "Journal entry updated successfully")
382 0 |> push_patch(to: socket.assigns.patch)}
383
384 0 {:error, %Ecto.Changeset{} = changeset} ->
385 {:noreply, assign(socket, form: to_form(changeset))}
386 end
387 end
388
389 0 defp save_journal_entry(socket, :new, journal_entry_params) do
390 0 case Journals.create_journal_entry(journal_entry_params) do
391 {:ok, journal_entry} ->
392 0 journal_entry =
393 [journal_entry | []]
394 |> Klepsidra.Journals.preload_journal_entry_type()
395 |> List.first()
396
397 0 notify_parent({:saved, journal_entry})
398
399 0 Tag.handle_tag_list_changes(
400 [],
401 0 socket.assigns.selected_tag_queue,
402 0 journal_entry.id,
403 &Categorisation.add_journal_entry_tag(&1, &2),
404 &Categorisation.delete_journal_entry_tag(&1, &2)
405 )
406
407 {:noreply,
408 socket
409 |> put_flash(:info, "Journal entry logged successfully")
410 0 |> push_patch(to: socket.assigns.patch)}
411
412 0 {:error, %Ecto.Changeset{} = changeset} ->
413 {:noreply, assign(socket, form: to_form(changeset))}
414 end
415 end
416
417 @spec assign_entry_type(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
418 defp assign_entry_type(socket) do
419 0 entry_types = JournalEntryTypes.populate_entry_types_list()
420
421 0 assign(socket, entry_types: entry_types)
422 end
423
424 0 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
425
426 defp value_mapper(location_id) when is_bitstring(location_id) do
427 0 City.city_option_for_select(location_id)
428 end
429
430 0 defp value_mapper(value), do: value
431
432 defp enable_tag_selector() do
433 JS.remove_class("hidden", to: "#journal_entry_ls_tag_search_text_input")
434 |> JS.remove_class("hidden", to: "#tag-selector__colour-select")
435 |> JS.add_class("hidden", to: "#tag-selector__add-button")
436 |> JS.add_class("gap-2", to: "#tag-selector")
437 |> JS.add_class("flex-auto", to: "#tag-selector__live-select")
438 0 |> JS.focus(to: "#journal_entry_ls_tag_search_text_input")
439 end
440 end

lib/klepsidra_web/live/journal_entry_live/index.ex

0.0
13
0
13
Line Hits Source
0 defmodule KlepsidraWeb.JournalEntryLive.Index do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.Journals
6 alias Klepsidra.Journals.JournalEntry
7 import LiveToast
8
9 @impl true
10 0 def mount(_params, _session, socket) do
11 0 datestamp = Date.utc_today() |> Date.to_string()
12
13 0 journal_entries =
14 Journals.list_journal_entries()
15 |> Journals.preload_journal_entry_type()
16
17 {:ok,
18 socket
19 |> assign(:datestamp, datestamp)
20 |> assign(:location_select_value, {"", ""})
21 |> stream(:journal_entries, journal_entries)}
22 end
23
24 @impl true
25 0 def handle_params(params, _url, socket) do
26 0 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
27 end
28
29 defp apply_action(socket, :edit, %{"id" => id}) do
30 socket
31 |> assign(:page_title, "Edit journal entry")
32 0 |> assign(:journal_entry, Journals.get_journal_entry!(id))
33 end
34
35 defp apply_action(socket, :new, _params) do
36 socket
37 |> assign(:page_title, "New journal entry")
38 0 |> assign(:journal_entry, %JournalEntry{})
39 end
40
41 defp apply_action(socket, :index, _params) do
42 socket
43 |> assign(:page_title, "Journal entries")
44 0 |> assign(:journal_entry, nil)
45 end
46
47 @impl true
48 0 def handle_info({KlepsidraWeb.JournalEntryLive.FormComponent, {:saved, journal_entry}}, socket) do
49 {:noreply, stream_insert(socket, :journal_entries, journal_entry)}
50 end
51
52 @impl true
53 0 def handle_event("delete", %{"id" => id}, socket) do
54 0 journal_entry = Journals.get_journal_entry!(id)
55 0 {:ok, _} = Journals.delete_journal_entry(journal_entry)
56
57 0 socket =
58 socket
59 |> stream_delete(:journal_entries, journal_entry)
60 |> put_toast(:info, "Journal entry deleted succesfully")
61
62 {:noreply, socket}
63 end
64 end

lib/klepsidra_web/live/journal_entry_live/show.ex

0.0
45
0
45
Line Hits Source
0 defmodule KlepsidraWeb.JournalEntryLive.Show do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.Journals
6 alias Klepsidra.Journals.JournalEntry
7 alias Klepsidra.Locations.City
8 alias Klepsidra.Categorisation
9 alias Klepsidra.Categorisation.Tag
10 alias KlepsidraWeb.TagLive.TagUtilities
11 alias LiveSelect.Component
12 alias Klepsidra.DynamicCSS
13
14 defmodule TagSearch do
15 @moduledoc """
16 The `TagSearch` module defines an embedded `tag_search` schema
17 containing the tags for this journal_entry.
18 """
19 use Ecto.Schema
20
21 import Ecto.Changeset
22
23 @type t :: %__MODULE__{
24 tag_search: Tag.t()
25 }
26 0 embedded_schema do
27 embeds_many(:tag_search, Tag, on_replace: :delete)
28 field(:bg_colour, :string)
29 end
30
31 @doc false
32 def changeset(schema \\ %__MODULE__{}, params) do
33 cast(schema, params, [])
34 0 |> cast_embed(:tag_search)
35 end
36 end
37
38 @tag_search_live_component_id "tag_form_tag_search_live_select_component"
39
40 @impl true
41 0 def mount(params, _session, socket) do
42 0 journal_entry_id = Map.get(params, "id")
43
44 0 journal_entry = Journals.get_journal_entry!(journal_entry_id) |> Klepsidra.Repo.preload(:tags)
45
46 0 socket =
47 socket
48 |> TagUtilities.generate_tag_options(
49 [],
50 0 Enum.map(journal_entry.tags, fn tag -> tag.id end),
51 @tag_search_live_component_id
52 )
53 |> Phx.Live.Head.push(
54 "style[id*=dynamic-style-block]",
55 :dynamic,
56 "style_declarations",
57 0 DynamicCSS.generate_tag_styles(journal_entry.tags)
58 )
59 |> assign(
60 live_select_form: to_form(TagSearch.changeset(%{}), as: "tag_form"),
61 new_tag_colour: {"#94a3b8", "#fff"}
62 )
63
64 {:ok, socket}
65 end
66
67 @impl true
68 0 def handle_params(%{"id" => id}, _, socket) do
69 0 journal_entry = get_journal(id)
70
71 0 journal_entry_type = get_journal_entry_type(journal_entry.entry_type_id |> to_string())
72 0 location_select_value = City.city_option_for_select(journal_entry.location_id)
73
74 {:noreply,
75 socket
76 0 |> assign(:page_title, page_title(socket.assigns.live_action))
77 |> assign(:journal_entry, journal_entry)
78 |> assign(:journal_entry_type, journal_entry_type)
79 0 |> assign(:location_formatted_name, location_select_value.label)}
80 end
81
82 @impl true
83 0 def handle_event(
84 "live_select_change",
85 %{
86 "field" => "tag_form_tag_search",
87 "id" => @tag_search_live_component_id,
88 "text" => tag_search_phrase
89 },
90 socket
91 ) do
92 0 tag_search_results =
93 Categorisation.search_tags_by_name_content(tag_search_phrase)
94 |> TagUtilities.tag_options_for_live_select()
95
96 0 send_update(Component,
97 id: @tag_search_live_component_id,
98 options: tag_search_results
99 )
100
101 0 socket =
102 socket
103 |> assign(
104 tag_search_phrase: tag_search_phrase,
105 possible_free_tag_entered: true
106 )
107
108 {:noreply, socket}
109 end
110
111 0 def handle_event(
112 "change",
113 %{
114 "_target" => ["tag_form", "tag_search_empty_selection"],
115 "tag_form" => %{
116 "tag_search_empty_selection" => "",
117 "tag_search_text_input" => _tag_search_phrase
118 }
119 },
120 socket
121 ) do
122 0 Tag.handle_tag_list_changes(
123 0 socket.assigns.selected_tag_queue,
124 [],
125 0 socket.assigns.journal_entry.id,
126 &Categorisation.add_journal_entry_tag(&1, &2),
127 &Categorisation.delete_journal_entry_tag(&1, &2)
128 )
129
130 0 socket =
131 socket
132 |> assign(
133 tag_search_phrase: nil,
134 possible_free_tag_entered: false
135 )
136
137 {:noreply, socket}
138 end
139
140 0 def handle_event(
141 "change",
142 %{
143 "_target" => ["tag_form", "tag_search"],
144 "tag_form" => %{
145 "tag_search" => selected_tags,
146 "tag_search_text_input" => _tag_search_phrase
147 }
148 },
149 socket
150 ) do
151 0 Tag.handle_tag_list_changes(
152 0 socket.assigns.selected_tag_queue,
153 selected_tags,
154 0 socket.assigns.journal_entry.id,
155 &Categorisation.add_journal_entry_tag(&1, &2),
156 &Categorisation.delete_journal_entry_tag(&1, &2)
157 )
158
159 0 socket =
160 TagUtilities.generate_tag_options(
161 socket,
162 0 socket.assigns.selected_tag_queue,
163 selected_tags,
164 @tag_search_live_component_id
165 )
166 |> Phx.Live.Head.push(
167 "style[id*=dynamic-style-block]",
168 :dynamic,
169 "style_declarations",
170 DynamicCSS.generate_tag_styles(selected_tags)
171 )
172 |> assign(
173 tag_search_phrase: nil,
174 possible_free_tag_entered: false
175 )
176
177 {:noreply, socket}
178 end
179
180 0 def handle_event(
181 "change",
182 %{
183 "_target" => ["tag_form", "bg_colour"],
184 "tag_form" => %{
185 "bg_colour" => bg_colour,
186 "tag_search_text_input" => _tag_search_phrase
187 }
188 },
189 socket
190 ) do
191 0 fg_colour =
192 case ColorContrast.calc_contrast(bg_colour) do
193 0 {:ok, fg_colour} -> fg_colour
194 0 {:error, _} -> "#fff"
195 end
196
197 0 socket =
198 socket
199 |> assign(new_tag_colour: {bg_colour, fg_colour})
200
201 {:noreply, socket}
202 end
203
204 0 def handle_event(
205 "ls_tag_search_blur",
206 %{"id" => @tag_search_live_component_id},
207 socket
208 ) do
209 0 socket =
210 socket
211 |> assign(
212 tag_search_phrase: nil,
213 possible_free_tag_entered: false
214 )
215
216 {:noreply, socket}
217 end
218
219 0 def handle_event(
220 "key_up",
221 %{"key" => "Enter"},
222 %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} =
223 socket
224 ) do
225 0 socket =
226 TagUtilities.handle_free_tagging(
227 socket,
228 tag_search_phrase,
229 String.length(tag_search_phrase),
230 @tag_search_live_component_id,
231 0 socket.assigns.new_tag_colour
232 )
233
234 {:noreply, socket}
235 end
236
237 0 def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket}
238
239 0 defp page_title(:show), do: "Show journal entry"
240 0 defp page_title(:edit), do: "Edit journal entry"
241
242 @spec get_journal(id :: Ecto.UUID.t()) :: JournalEntry.t()
243 0 defp get_journal(id), do: Journals.get_journal_entry!(id)
244
245 @spec get_journal_entry_type(journal_entry_type_id :: Ecto.UUID.t()) ::
246 String.t()
247 defp get_journal_entry_type(journal_entry_type_id) when is_bitstring(journal_entry_type_id) do
248 journal_entry_type_id
249 |> Journals.get_journal_entry_types!()
250 0 |> Map.get(:name)
251 end
252
253 defp enable_tag_selector() do
254 JS.remove_class("hidden", to: "#tag_form_tag_search_text_input")
255 |> JS.remove_class("hidden", to: "#tag-selector__colour-select--show")
256 |> JS.add_class("hidden", to: "#tag-selector__add-button--show")
257 |> JS.add_class("gap-2", to: "#tag-selector--show")
258 |> JS.add_class("flex-auto", to: "#tag-selector__live-select--show")
259 0 |> JS.focus(to: "#tag_form_tag_search_text_input")
260 end
261 end

lib/klepsidra_web/live/journal_entry_types_live/form_component.ex

44.8
29
26
16
Line Hits Source
0 defmodule KlepsidraWeb.JournalEntryTypesLive.FormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4
5 alias Klepsidra.Journals
6
7 @impl true
8 def render(assigns) do
9 2 ~H"""
10 <div>
11 2 <.header>
12 2 <%= @title %>
13 2 <:subtitle>Use this form to manage journal_entry_types records in your database.</:subtitle>
14 </.header>
15
16 2 <.simple_form
17 2 for={@form}
18 id="journal_entry_types-form"
19 2 phx-target={@myself}
20 phx-change="validate"
21 phx-submit="save"
22 >
23 2 <.input field={@form[:name]} type="text" label="Name" />
24 2 <.input field={@form[:description]} type="text" label="Description" />
25 2 <:actions>
26 2 <.button phx-disable-with="Saving...">Save Journal entry types</.button>
27 </:actions>
28 </.simple_form>
29 </div>
30 """
31 end
32
33 @impl true
34 2 def update(%{journal_entry_types: journal_entry_types} = assigns, socket) do
35 {:ok,
36 socket
37 |> assign(assigns)
38 |> assign_new(:form, fn ->
39 2 to_form(Journals.change_journal_entry_types(journal_entry_types))
40 end)}
41 end
42
43 @impl true
44 0 def handle_event("validate", %{"journal_entry_types" => journal_entry_types_params}, socket) do
45 0 changeset =
46 Journals.change_journal_entry_types(
47 0 socket.assigns.journal_entry_types,
48 journal_entry_types_params
49 )
50
51 {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
52 end
53
54 def handle_event("save", %{"journal_entry_types" => journal_entry_types_params}, socket) do
55 0 save_journal_entry_types(socket, socket.assigns.action, journal_entry_types_params)
56 end
57
58 0 defp save_journal_entry_types(socket, :edit, journal_entry_types_params) do
59 0 case Journals.update_journal_entry_types(
60 0 socket.assigns.journal_entry_types,
61 journal_entry_types_params
62 ) do
63 {:ok, journal_entry_types} ->
64 0 notify_parent({:saved, journal_entry_types})
65
66 {:noreply,
67 socket
68 |> put_flash(:info, "Journal entry types updated successfully")
69 0 |> push_patch(to: socket.assigns.patch)}
70
71 0 {:error, %Ecto.Changeset{} = changeset} ->
72 {:noreply, assign(socket, form: to_form(changeset))}
73 end
74 end
75
76 0 defp save_journal_entry_types(socket, :new, journal_entry_types_params) do
77 0 case Journals.create_journal_entry_types(journal_entry_types_params) do
78 {:ok, journal_entry_types} ->
79 0 notify_parent({:saved, journal_entry_types})
80
81 {:noreply,
82 socket
83 |> put_flash(:info, "Journal entry types created successfully")
84 0 |> push_patch(to: socket.assigns.patch)}
85
86 0 {:error, %Ecto.Changeset{} = changeset} ->
87 {:noreply, assign(socket, form: to_form(changeset))}
88 end
89 end
90
91 0 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
92 end

lib/klepsidra_web/live/journal_entry_types_live/index.ex

50.0
10
19
5
Line Hits Source
0 defmodule KlepsidraWeb.JournalEntryTypesLive.Index do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.Journals
6 alias Klepsidra.Journals.JournalEntryTypes
7
8 @impl true
9 4 def mount(_params, _session, socket) do
10 {:ok, stream(socket, :journal_entry_types_collection, Journals.list_journal_entry_types())}
11 end
12
13 @impl true
14 5 def handle_params(params, _url, socket) do
15 5 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
16 end
17
18 defp apply_action(socket, :edit, %{"id" => id}) do
19 socket
20 |> assign(:page_title, "Edit Journal entry types")
21 0 |> assign(:journal_entry_types, Journals.get_journal_entry_types!(id))
22 end
23
24 defp apply_action(socket, :new, _params) do
25 socket
26 |> assign(:page_title, "New Journal entry types")
27 1 |> assign(:journal_entry_types, %JournalEntryTypes{})
28 end
29
30 defp apply_action(socket, :index, _params) do
31 socket
32 |> assign(:page_title, "Listing Journal entry types")
33 4 |> assign(:journal_entry_types, nil)
34 end
35
36 @impl true
37 0 def handle_info(
38 {KlepsidraWeb.JournalEntryTypesLive.FormComponent, {:saved, journal_entry_types}},
39 socket
40 ) do
41 {:noreply, stream_insert(socket, :journal_entry_types_collection, journal_entry_types)}
42 end
43
44 @impl true
45 0 def handle_event("delete", %{"id" => id}, socket) do
46 0 journal_entry_types = Journals.get_journal_entry_types!(id)
47 0 {:ok, _} = Journals.delete_journal_entry_types(journal_entry_types)
48
49 {:noreply, stream_delete(socket, :journal_entry_types_collection, journal_entry_types)}
50 end
51 end

lib/klepsidra_web/live/journal_entry_types_live/show.ex

100.0
5
19
0
Line Hits Source
0 defmodule KlepsidraWeb.JournalEntryTypesLive.Show do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.Journals
6
7 @impl true
8 4 def mount(_params, _session, socket) do
9 {:ok, socket}
10 end
11
12 @impl true
13 5 def handle_params(%{"id" => id}, _, socket) do
14 {:noreply,
15 socket
16 5 |> assign(:page_title, page_title(socket.assigns.live_action))
17 |> assign(:journal_entry_types, Journals.get_journal_entry_types!(id))}
18 end
19
20 4 defp page_title(:show), do: "Show Journal entry types"
21 1 defp page_title(:edit), do: "Edit Journal entry types"
22 end

lib/klepsidra_web/live/note_live/note_form_component.ex

31.4
35
22
24
Line Hits Source
0 defmodule KlepsidraWeb.Live.NoteLive.NoteFormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4 alias Klepsidra.TimeTracking
5 alias Klepsidra.TimeTracking.Note
6
7 @impl true
8 def render(assigns) do
9 2 ~H"""
10 <div>
11 2 <.simple_form
12 2 for={@note_form}
13 id="note-form"
14 phx-submit="save"
15 phx-change="validate"
16 2 phx-target={@myself}
17 2 phx-value-id={@note_form.data.id}
18 >
19 2 <.input
20 2 field={@note_form[:note]}
21 type="textarea"
22 placeholder="Type a new note here"
23 autocomplete="off"
24 />
25 2 <.button phx-disable-with="Saving note...">Save note</.button>
26 </.simple_form>
27 </div>
28 """
29 end
30
31 @impl true
32 0 def update(%{note: note} = assigns, socket) do
33 0 changeset = TimeTracking.change_note(note)
34
35 0 socket =
36 socket
37 |> assign_form(changeset)
38 |> assign(assigns)
39
40 {:ok, socket}
41 end
42
43 @impl true
44 2 def update(assigns, socket) do
45 2 changeset = TimeTracking.change_note(%Note{})
46
47 {:ok,
48 socket
49 |> assign_form(changeset)
50 |> assign(assigns)}
51 end
52
53 @impl true
54 0 def handle_event("validate", %{"note" => note_params}, socket) do
55 0 changeset =
56 %Note{}
57 |> TimeTracking.change_note(note_params)
58 |> Map.put(:action, :validate)
59
60 {:noreply, assign_form(socket, changeset)}
61 end
62
63 def handle_event("save", %{"note" => note_params}, socket) do
64 0 note_params = Map.put(note_params, "timer_id", socket.assigns.timer_id)
65
66 0 save_note(socket, socket.assigns.action, note_params)
67 end
68
69 0 defp save_note(socket, :edit_note, note_params) do
70 0 case TimeTracking.update_note(socket.assigns.note, note_params) do
71 {:ok, note} ->
72 0 notify_parent({:updated_note, note})
73
74 {:noreply,
75 socket
76 0 |> push_patch(to: socket.assigns.patch)}
77
78 0 {:error, %Ecto.Changeset{} = changeset} ->
79 {:noreply, assign_form(socket, changeset)}
80 end
81 end
82
83 0 defp save_note(socket, :new_note, note_params) do
84 0 case TimeTracking.create_note(note_params) do
85 {:ok, note} ->
86 0 notify_parent({:saved_note, note})
87
88 0 changeset =
89 TimeTracking.change_note(%Note{})
90
91 {
92 :noreply,
93 socket
94 |> assign_form(changeset)
95 0 |> push_patch(to: socket.assigns.patch)
96 }
97
98 0 {:error, %Ecto.Changeset{} = changeset} ->
99 {:noreply, assign_form(socket, changeset)}
100 end
101 end
102
103 0 defp save_note(socket, :new_embedded_note, note_params) do
104 0 case TimeTracking.create_note(note_params) do
105 {:ok, note} ->
106 0 notify_parent({:saved_note, note})
107
108 0 changeset =
109 TimeTracking.change_note(%Note{})
110
111 {
112 :noreply,
113 socket
114 |> assign_form(changeset)
115 }
116
117 0 {:error, %Ecto.Changeset{} = changeset} ->
118 {:noreply, assign_form(socket, changeset)}
119 end
120 end
121
122 defp assign_form(socket, %Ecto.Changeset{} = changeset) do
123 2 assign(socket, :note_form, to_form(changeset))
124 end
125
126 0 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
127 end

lib/klepsidra_web/live/project_live/form_component.ex

46.9
81
165
43
Line Hits Source
0 defmodule KlepsidraWeb.ProjectLive.FormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4 import LiveToast
5 alias Klepsidra.Projects
6 alias Klepsidra.Categorisation
7 alias Klepsidra.Categorisation.Tag
8 alias KlepsidraWeb.TagLive.TagUtilities
9 alias Klepsidra.DynamicCSS
10
11 @tag_search_live_component_id "project_ls_tag_search_live_select_component"
12
13 @impl true
14 def render(assigns) do
15 7 ~H"""
16 <div>
17 3 <.header>
18 3 <%= @title %>
19 </.header>
20
21 7 <.simple_form
22 7 for={@form}
23 id="project-form"
24 7 phx-target={@myself}
25 phx-change="validate"
26 phx-window-keyup="key_up"
27 phx-submit="save"
28 >
29 7 <.input field={@form[:name]} type="text" label="Name" />
30 7 <.input field={@form[:description]} type="textarea" label="Description" />
31 7 <.input :if={@action == :edit} field={@form[:active]} type="checkbox" label="Active?" />
32
33 3 <div id="tag-selector" class={"flex #{if @selected_tag_queue != [], do: "gap-2"}"}>
34 3 <div
35 id="tag-selector__live-select"
36 phx-mounted={JS.add_class("hidden", to: "#project_ls_tag_search_text_input")}
37 >
38 7 <.live_select
39 7 field={@form[:ls_tag_search]}
40 mode={:tags}
41 label=""
42 options={[]}
43 placeholder="Add tag"
44 debounce={80}
45 clear_tag_button_class="cursor-pointer px-1 rounded-r-md"
46 dropdown_extra_class="bg-white max-h-48 overflow-y-scroll"
47 tag_class="bg-slate-400 text-white flex rounded-md text-sm font-semibold"
48 tags_container_class="flex flex-wrap gap-2"
49 container_extra_class="rounded border border-none"
50 update_min_len={1}
51 user_defined_options="true"
52 7 value={@selected_tags}
53 phx-blur="ls_tag_search_blur"
54 7 phx-target={@myself}
55 >
56 0 <:option :let={option}>
57 0 <div class="flex" title={option.description}>
58 0 <%= option.label %>
59 </div>
60 </:option>
61 0 <:tag :let={option}>
62 0 <div class={"#{option.tag_class} py-1.5 px-3 rounded-l-md"} title={option.description}>
63 0 <.link navigate={~p"/tags/#{option.value}"}>
64 0 <%= option.label %>
65 </.link>
66 </div>
67 </:tag>
68 </.live_select>
69 </div>
70
71 <div
72 id="tag-selector__colour-select"
73 class="tag-colour-picker hidden w-10 overflow-hidden self-end shrink-0"
74 >
75 7 <.input field={@form[:bg_colour]} type="color" value={elem(@new_tag_colour, 0)} />
76 </div>
77
78 3 <.button
79 id="tag-selector__add-button"
80 class="add-tag-button flex-none flex-grow-0 h-fit self-end [&&]:bg-violet-50 [&&]:text-indigo-900 [&&]:py-1 rounded-md"
81 type="button"
82 phx-click={enable_tag_selector()}
83 >
84 Add tag +
85 </.button>
86 </div>
87
88 7 <:actions>
89 7 <.button phx-disable-with="Saving...">Save</.button>
90 </:actions>
91 </.simple_form>
92 </div>
93 """
94 end
95
96 @impl true
97 3 def update(%{project: project} = assigns, socket) do
98 3 changeset = Projects.change_project(project)
99
100 3 project = project |> Klepsidra.Repo.preload(:tags)
101
102 3 socket =
103 socket
104 |> TagUtilities.generate_tag_options(
105 [],
106 3 Enum.map(project.tags, fn tag -> tag.id end),
107 @tag_search_live_component_id
108 )
109 |> Phx.Live.Head.push(
110 "style[id*=dynamic-style-block]",
111 :dynamic,
112 "style_declarations",
113 3 DynamicCSS.generate_tag_styles(project.tags)
114 )
115 |> assign(assigns)
116 |> assign(new_tag_colour: {"#94a3b8", "#fff"})
117 |> assign_form(changeset)
118
119 {:ok, socket}
120 end
121
122 @impl true
123 0 def handle_event(
124 "validate",
125 %{
126 "_target" => ["project", "ls_tag_search"],
127 "project" => %{"ls_tag_search" => tags_applied}
128 },
129 socket
130 ) do
131 0 Tag.handle_tag_list_changes(
132 0 socket.assigns.selected_tag_queue,
133 tags_applied,
134 0 socket.assigns.project.id,
135 &Categorisation.add_project_tag(&1, &2),
136 &Categorisation.delete_project_tag(&1, &2)
137 )
138
139 0 socket =
140 TagUtilities.generate_tag_options(
141 socket,
142 0 socket.assigns.selected_tag_queue,
143 tags_applied,
144 @tag_search_live_component_id,
145 0 parent_tag_select_id: socket.assigns.parent_tag_select_id
146 )
147 |> Phx.Live.Head.push(
148 "style[id*=dynamic-style-block]",
149 :dynamic,
150 "style_declarations",
151 DynamicCSS.generate_tag_styles(tags_applied)
152 )
153 |> assign(
154 tag_search_phrase: nil,
155 possible_free_tag_entered: false
156 )
157
158 {:noreply, socket}
159 end
160
161 @doc """
162 Validate event which fires only once the last of the tags has been cleared
163 from a `live_select` component.
164 """
165 0 def handle_event(
166 "validate",
167 %{
168 "_target" => ["project", "ls_tag_search_empty_selection"],
169 "project" => %{"ls_tag_search_empty_selection" => ""}
170 },
171 socket
172 ) do
173 0 Tag.handle_tag_list_changes(
174 0 socket.assigns.selected_tag_queue,
175 [],
176 0 socket.assigns.project.id,
177 &Categorisation.add_project_tag(&1, &2),
178 &Categorisation.delete_project_tag(&1, &2)
179 )
180
181 0 socket.assigns.parent_tag_select_id &&
182 0 send_update(LiveSelect.Component, id: socket.assigns.parent_tag_select_id, value: [])
183
184 0 socket =
185 socket
186 |> assign(
187 tag_search_phrase: nil,
188 possible_free_tag_entered: false
189 )
190
191 {:noreply, socket}
192 end
193
194 0 def handle_event(
195 "validate",
196 %{
197 "_target" => ["project", "bg_colour"],
198 "project" => %{
199 "bg_colour" => bg_colour
200 }
201 },
202 socket
203 ) do
204 0 fg_colour =
205 case ColorContrast.calc_contrast(bg_colour) do
206 0 {:ok, fg_colour} -> fg_colour
207 0 {:error, _} -> "#fff"
208 end
209
210 0 socket =
211 socket
212 |> assign(new_tag_colour: {bg_colour, fg_colour})
213
214 {:noreply, socket}
215 end
216
217 3 def handle_event("validate", %{"project" => project_params}, socket) do
218 3 changeset =
219 3 socket.assigns.project
220 |> Projects.change_project(project_params)
221 |> Map.put(:action, :validate)
222
223 {:noreply, assign_form(socket, changeset)}
224 end
225
226 def handle_event("save", %{"project" => project_params}, socket) do
227 3 save_project(socket, socket.assigns.action, project_params)
228 end
229
230 0 def handle_event(
231 "live_select_change",
232 %{
233 "field" => "project_ls_tag_search",
234 "id" => live_select_id,
235 "text" => tag_search_phrase
236 },
237 socket
238 ) do
239 0 tag_search_results =
240 Categorisation.search_tags_by_name_content(tag_search_phrase)
241 |> TagUtilities.tag_options_for_live_select()
242
243 0 send_update(LiveSelect.Component, id: live_select_id, options: tag_search_results)
244
245 0 socket =
246 socket
247 |> assign(
248 tag_search_phrase: tag_search_phrase,
249 possible_free_tag_entered: true
250 )
251
252 {:noreply, socket}
253 end
254
255 0 def handle_event(
256 "ls_tag_search_blur",
257 %{"id" => @tag_search_live_component_id},
258 socket
259 ) do
260 0 socket =
261 socket
262 |> assign(
263 tag_search_phrase: nil,
264 possible_free_tag_entered: false
265 )
266
267 {:noreply, socket}
268 end
269
270 0 def handle_event(
271 "key_up",
272 %{"key" => "Enter"},
273 %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} =
274 socket
275 ) do
276 0 socket =
277 TagUtilities.handle_free_tagging(
278 socket,
279 tag_search_phrase,
280 String.length(tag_search_phrase),
281 @tag_search_live_component_id,
282 0 socket.assigns.new_tag_colour
283 )
284
285 {:noreply, socket}
286 end
287
288 0 def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket}
289
290 2 defp save_project(socket, :edit, project_params) do
291 2 case Projects.update_project(socket.assigns.project, project_params) do
292 {:ok, project} ->
293 2 notify_parent({:saved, project})
294
295 {:noreply,
296 socket
297 |> put_toast(:info, "Project updated successfully")
298 2 |> push_patch(to: socket.assigns.patch)}
299
300 0 {:error, %Ecto.Changeset{} = changeset} ->
301 {:noreply, assign_form(socket, changeset)}
302 end
303 end
304
305 0 defp save_project(socket, :new, project_params) do
306 1 case Projects.create_project(project_params) do
307 {:ok, project} ->
308 0 notify_parent({:saved, project})
309
310 0 Tag.handle_tag_list_changes(
311 [],
312 0 socket.assigns.selected_tag_queue,
313 0 project.id,
314 &Categorisation.add_project_tag(&1, &2),
315 &Categorisation.delete_project_tag(&1, &2)
316 )
317
318 {:noreply,
319 socket
320 |> put_toast(:info, "Project created successfully")
321 0 |> push_patch(to: socket.assigns.patch)}
322
323 1 {:error, %Ecto.Changeset{} = changeset} ->
324 {:noreply, assign_form(socket, changeset)}
325 end
326 end
327
328 defp assign_form(socket, %Ecto.Changeset{} = changeset) do
329 7 assign(socket, :form, to_form(changeset))
330 end
331
332 2 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
333
334 defp enable_tag_selector() do
335 JS.remove_class("hidden", to: "#project_ls_tag_search_text_input")
336 |> JS.remove_class("hidden", to: "#tag-selector__colour-select")
337 |> JS.add_class("hidden", to: "#tag-selector__add-button")
338 |> JS.add_class("gap-2", to: "#tag-selector")
339 |> JS.add_class("flex-auto", to: "#tag-selector__live-select")
340 3 |> JS.focus(to: "#project_ls_tag_search_text_input")
341 end
342 end

lib/klepsidra_web/live/project_live/index.ex

100.0
11
46
0
Line Hits Source
0 defmodule KlepsidraWeb.ProjectLive.Index do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4 import LiveToast
5
6 alias Klepsidra.Projects
7 alias Klepsidra.Projects.Project
8
9 @impl true
10 8 def mount(_params, _session, socket) do
11 {:ok, stream(socket, :projects, Projects.list_projects())}
12 end
13
14 @impl true
15 11 def handle_params(params, _url, socket) do
16 11 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
17 end
18
19 defp apply_action(socket, :edit, %{"id" => id}) do
20 socket
21 |> assign(:page_title, "Edit Project")
22 1 |> assign(:project, Projects.get_project!(id))
23 end
24
25 defp apply_action(socket, :new, _params) do
26 socket
27 |> assign(:page_title, "New Project")
28 1 |> assign(:project, %Project{})
29 end
30
31 defp apply_action(socket, :index, _params) do
32 socket
33 |> assign(:page_title, "Projects")
34 9 |> assign(:project, nil)
35 end
36
37 @impl true
38 1 def handle_info({KlepsidraWeb.ProjectLive.FormComponent, {:saved, project}}, socket) do
39 {:noreply, stream_insert(socket, :projects, project)}
40 end
41
42 @impl true
43 1 def handle_event("delete", %{"id" => id}, socket) do
44 1 project = Projects.get_project!(id)
45 1 {:ok, _} = Projects.delete_project(project)
46
47 {:noreply, handle_deleted_project(socket, project, :projects)}
48 end
49
50 defp handle_deleted_project(socket, proiect, source_stream) do
51 socket
52 |> stream_delete(source_stream, proiect)
53 1 |> put_toast(:info, "Project deleted successfully")
54 end
55 end

lib/klepsidra_web/live/project_live/show.ex

45.8
48
111
26
Line Hits Source
0 defmodule KlepsidraWeb.ProjectLive.Show do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.Projects
6 alias Klepsidra.TimeTracking.Timer
7 alias Klepsidra.Cldr.Unit
8 alias Klepsidra.Categorisation
9 alias Klepsidra.Categorisation.Tag
10 alias KlepsidraWeb.TagLive.TagUtilities
11 alias LiveSelect.Component
12 alias Klepsidra.DynamicCSS
13
14 defmodule TagSearch do
15 @moduledoc """
16 The `TagSearch` module defines an embedded `tag_search` schema
17 containing the tags for this project.
18 """
19 use Ecto.Schema
20
21 import Ecto.Changeset
22
23 @type t :: %__MODULE__{
24 tag_search: Tag.t()
25 }
26 12 embedded_schema do
27 embeds_many(:tag_search, Tag, on_replace: :delete)
28 field(:bg_colour, :string)
29 end
30
31 @doc false
32 def changeset(schema \\ %__MODULE__{}, params) do
33 cast(schema, params, [])
34 4 |> cast_embed(:tag_search)
35 end
36 end
37
38 @tag_search_live_component_id "tag_form_tag_search_live_select_component"
39
40 @impl true
41 4 def mount(params, _session, socket) do
42 4 project_id = Map.get(params, "id")
43
44 4 project = Klepsidra.Projects.get_project!(project_id) |> Klepsidra.Repo.preload(:tags)
45
46 4 socket =
47 socket
48 |> TagUtilities.generate_tag_options(
49 [],
50 4 Enum.map(project.tags, fn tag -> tag.id end),
51 @tag_search_live_component_id
52 )
53 |> Phx.Live.Head.push(
54 "style[id*=dynamic-style-block]",
55 :dynamic,
56 "style_declarations",
57 4 DynamicCSS.generate_tag_styles(project.tags)
58 )
59 |> assign(
60 live_select_form: to_form(TagSearch.changeset(%{}), as: "tag_form"),
61 new_tag_colour: {"#94a3b8", "#fff"}
62 )
63
64 {:ok, socket}
65 end
66
67 @impl true
68 6 def handle_params(%{"id" => id}, _, socket) do
69 6 aggregate_project_duration =
70 get_aggregate_duration_for_project(id)
71
72 6 socket =
73 socket
74 6 |> assign(:page_title, page_title(socket.assigns.live_action))
75 |> assign(:project, Projects.get_project!(id))
76 |> assign(
77 6 aggregate_project_duration: aggregate_project_duration.base_unit_duration,
78 6 duration_in_hours: aggregate_project_duration.duration_in_hours,
79 6 human_readable_duration: aggregate_project_duration.human_readable_duration
80 )
81
82 {:noreply, socket}
83 end
84
85 @impl true
86 0 def handle_event(
87 "live_select_change",
88 %{
89 "field" => "tag_form_tag_search",
90 "id" => @tag_search_live_component_id,
91 "text" => tag_search_phrase
92 },
93 socket
94 ) do
95 0 tag_search_results =
96 Categorisation.search_tags_by_name_content(tag_search_phrase)
97 |> TagUtilities.tag_options_for_live_select()
98
99 0 send_update(Component,
100 id: @tag_search_live_component_id,
101 options: tag_search_results
102 )
103
104 0 socket =
105 socket
106 |> assign(
107 tag_search_phrase: tag_search_phrase,
108 possible_free_tag_entered: true
109 )
110
111 {:noreply, socket}
112 end
113
114 0 def handle_event(
115 "change",
116 %{
117 "_target" => ["tag_form", "tag_search_empty_selection"],
118 "tag_form" => %{
119 "tag_search_empty_selection" => "",
120 "tag_search_text_input" => _tag_search_phrase
121 }
122 },
123 socket
124 ) do
125 0 Tag.handle_tag_list_changes(
126 0 socket.assigns.selected_tag_queue,
127 [],
128 0 socket.assigns.project.id,
129 &Categorisation.add_project_tag(&1, &2),
130 &Categorisation.delete_project_tag(&1, &2)
131 )
132
133 0 socket =
134 socket
135 |> assign(
136 tag_search_phrase: nil,
137 possible_free_tag_entered: false
138 )
139
140 {:noreply, socket}
141 end
142
143 0 def handle_event(
144 "change",
145 %{
146 "_target" => ["tag_form", "tag_search"],
147 "tag_form" => %{
148 "tag_search" => selected_tags,
149 "tag_search_text_input" => _tag_search_phrase
150 }
151 },
152 socket
153 ) do
154 0 Tag.handle_tag_list_changes(
155 0 socket.assigns.selected_tag_queue,
156 selected_tags,
157 0 socket.assigns.project.id,
158 &Categorisation.add_project_tag(&1, &2),
159 &Categorisation.delete_project_tag(&1, &2)
160 )
161
162 0 socket =
163 TagUtilities.generate_tag_options(
164 socket,
165 0 socket.assigns.selected_tag_queue,
166 selected_tags,
167 @tag_search_live_component_id
168 )
169 |> Phx.Live.Head.push(
170 "style[id*=dynamic-style-block]",
171 :dynamic,
172 "style_declarations",
173 DynamicCSS.generate_tag_styles(selected_tags)
174 )
175 |> assign(
176 tag_search_phrase: nil,
177 possible_free_tag_entered: false
178 )
179
180 {:noreply, socket}
181 end
182
183 0 def handle_event(
184 "change",
185 %{
186 "_target" => ["tag_form", "bg_colour"],
187 "tag_form" => %{
188 "bg_colour" => bg_colour,
189 "tag_search_text_input" => _tag_search_phrase
190 }
191 },
192 socket
193 ) do
194 0 fg_colour =
195 case ColorContrast.calc_contrast(bg_colour) do
196 0 {:ok, fg_colour} -> fg_colour
197 0 {:error, _} -> "#fff"
198 end
199
200 0 socket =
201 socket
202 |> assign(new_tag_colour: {bg_colour, fg_colour})
203
204 {:noreply, socket}
205 end
206
207 0 def handle_event(
208 "ls_tag_search_blur",
209 %{"id" => @tag_search_live_component_id},
210 socket
211 ) do
212 0 socket =
213 socket
214 |> assign(
215 tag_search_phrase: nil,
216 possible_free_tag_entered: false
217 )
218
219 {:noreply, socket}
220 end
221
222 0 def handle_event(
223 "key_up",
224 %{"key" => "Enter"},
225 %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} =
226 socket
227 ) do
228 0 socket =
229 TagUtilities.handle_free_tagging(
230 socket,
231 tag_search_phrase,
232 String.length(tag_search_phrase),
233 @tag_search_live_component_id,
234 0 socket.assigns.new_tag_colour
235 )
236
237 {:noreply, socket}
238 end
239
240 0 def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket}
241
242 5 defp page_title(:show), do: "Show Project"
243 1 defp page_title(:edit), do: "Edit Project"
244
245 @impl true
246 1 def handle_info({KlepsidraWeb.ProjectLive.FormComponent, {:saved, _project}}, socket) do
247 {:noreply, socket}
248 end
249
250 defp get_aggregate_duration_for_project(project_id) do
251 project_id
252 |> Klepsidra.TimeTracking.get_closed_timer_durations_for_project()
253 |> Timer.convert_durations_to_base_time_unit()
254 |> Timer.sum_base_unit_durations()
255 6 |> format_aggregate_duration_for_project()
256 end
257
258 defp format_aggregate_duration_for_project(base_unit_duration) do
259 6 %{
260 base_unit_duration: base_unit_duration,
261 duration_in_hours:
262 base_unit_duration
263 |> Unit.convert!(:hour)
264 6 |> then(fn i -> Cldr.Unit.round(i, 1) end)
265 |> Unit.to_string!(),
266 human_readable_duration:
267 Timer.format_human_readable_duration(base_unit_duration,
268 unit_list: [
269 :day,
270 :hour_increment
271 ],
272 return_if_short_duration: false
273 )
274 }
275 end
276
277 defp enable_tag_selector() do
278 JS.remove_class("hidden", to: "#tag_form_tag_search_text_input")
279 |> JS.remove_class("hidden", to: "#tag-selector__colour-select--show")
280 |> JS.add_class("hidden", to: "#tag-selector__add-button--show")
281 |> JS.add_class("gap-2", to: "#tag-selector--show")
282 |> JS.add_class("flex-auto", to: "#tag-selector__live-select--show")
283 4 |> JS.focus(to: "#tag_form_tag_search_text_input")
284 end
285 end

lib/klepsidra_web/live/reporting/activity_time_reporting.ex

0.0
45
0
45
Line Hits Source
0 defmodule KlepsidraWeb.TimerLive.ActivityTimeReporting do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.TimeTracking
6 import LiveToast
7
8 @impl true
9 0 def mount(_params, _session, socket) do
10 0 filter = %{
11 from: "",
12 to: "",
13 project_id: "",
14 business_partner_id: "",
15 activity_type_id: "",
16 billable: "",
17 modified: ""
18 }
19
20 0 projects = Klepsidra.Projects.list_active_projects()
21 0 customers = Klepsidra.BusinessPartners.list_active_customers()
22 0 activity_types = Klepsidra.TimeTracking.list_active_activity_types()
23 0 filtered_timers = TimeTracking.list_timers_with_statistics(filter)
24
25 {:ok,
26 socket
27 |> assign(
28 display_help: false,
29 filter: filter,
30 projects: projects,
31 customers: customers,
32 activity_types: activity_types,
33 0 timer_count: filtered_timers.meta.timer_count,
34 0 aggregate_duration: filtered_timers.meta.aggregate_duration,
35 0 average_duration: filtered_timers.meta.average_timer_duration,
36 0 aggregate_billing_duration: filtered_timers.meta.aggregate_billing_duration
37 )
38 0 |> stream(:timers, filtered_timers.timer_list)}
39 end
40
41 @impl true
42 0 def handle_params(params, _url, socket) do
43 0 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
44 end
45
46 defp apply_action(socket, :edit_timer, %{"id" => id}) do
47 socket
48 |> assign(:page_title, "Edit Timer")
49 0 |> assign(:timer, TimeTracking.get_timer!(id))
50 end
51
52 defp apply_action(socket, :index, _params) do
53 socket
54 |> assign(:page_title, "Activity Timers")
55 0 |> assign(:timer, nil)
56 end
57
58 defp apply_action(socket, :new_note, %{"id" => id} = _params) do
59 socket
60 |> assign(:page_title, "New note")
61 0 |> assign(:timer_id, id)
62 end
63
64 @impl true
65 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_open_timer, timer}}, socket) do
66 {:noreply, handle_open_timer(socket, timer)}
67 end
68
69 @impl true
70 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_closed_timer, timer}}, socket) do
71 {:noreply, handle_closed_timer(socket, timer)}
72 end
73
74 @impl true
75 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_open_timer, timer}}, socket) do
76 {:noreply, handle_updated_timer(socket, timer)}
77 end
78
79 @impl true
80 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_closed_timer, timer}}, socket) do
81 {:noreply, handle_updated_timer(socket, timer)}
82 end
83
84 @impl true
85 0 def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_started, timer}}, socket) do
86 {:noreply, handle_started_timer(socket, timer)}
87 end
88
89 @impl true
90 0 def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_stopped, timer}}, socket) do
91 {:noreply, handle_closed_timer(socket, timer)}
92 end
93
94 @impl true
95 0 def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:saved_note, note}}, socket) do
96 {:noreply, handle_saved_note(socket, note)}
97 end
98
99 @impl true
100 0 def handle_event("delete", %{"id" => id}, socket) do
101 0 timer = TimeTracking.get_timer!(id)
102 0 {:ok, _} = TimeTracking.delete_timer(timer)
103
104 {:noreply, handle_deleted_timer(socket, timer, :timers)}
105 end
106
107 @impl true
108 0 def handle_event(
109 "filter",
110 %{
111 "from" => from,
112 "to" => to,
113 "project_id" => project_id,
114 "business_partner_id" => business_partner_id,
115 "activity_type_id" => activity_type_id,
116 "billable" => billable,
117 "modified" => modified
118 },
119 socket
120 ) do
121 0 from = parse_date(from)
122 0 to = parse_date(to)
123
124 0 filter = %{
125 from: from,
126 to: to,
127 project_id: project_id,
128 business_partner_id: business_partner_id,
129 activity_type_id: activity_type_id,
130 billable: billable,
131 modified: modified
132 }
133
134 0 filtered_timers = TimeTracking.list_timers_with_statistics(filter)
135
136 0 socket =
137 socket
138 |> assign(
139 filter: filter,
140 0 timer_count: filtered_timers.meta.timer_count,
141 0 aggregate_duration: filtered_timers.meta.aggregate_duration,
142 0 average_duration: filtered_timers.meta.average_timer_duration,
143 0 aggregate_billing_duration: filtered_timers.meta.aggregate_billing_duration
144 )
145 0 |> stream(:timers, filtered_timers.timer_list, reset: true)
146
147 {:noreply, socket}
148 end
149
150 defp handle_started_timer(socket, timer) do
151 socket
152 |> stream_insert(:timers, timer, at: 0)
153 0 |> put_toast(:info, "Timer started")
154 end
155
156 defp handle_open_timer(socket, timer) do
157 socket
158 |> stream_insert(:timers, timer)
159 0 |> put_toast(:info, "Timer created successfully")
160 end
161
162 defp handle_closed_timer(socket, timer) do
163 socket
164 |> stream_insert(:timers, timer)
165 0 |> put_toast(:info, "Timer stopped")
166 end
167
168 defp handle_updated_timer(socket, timer) do
169 socket
170 |> stream_insert(:timers, timer)
171 0 |> put_toast(:info, "Timer updated successfully")
172 end
173
174 defp handle_deleted_timer(socket, timer, source_stream) do
175 socket
176 |> stream_delete(source_stream, timer)
177 0 |> put_toast(:info, "Timer deleted successfully")
178 end
179
180 defp handle_saved_note(socket, _note) do
181 socket
182 0 |> put_toast(:info, "Note created successfully")
183 end
184
185 0 defp parse_date(""), do: ""
186
187 defp parse_date(date) when is_bitstring(date) do
188 0 Timex.parse!(date, "{YYYY}-{0M}-{D}") |> NaiveDateTime.to_string()
189 end
190 end

lib/klepsidra_web/live/start_page_live.ex

0.0
91
0
91
Line Hits Source
0 defmodule KlepsidraWeb.StartPageLive do
1 @moduledoc """
2 Klepsidra's home page, where every user is expected to start their
3 interaction with the application.
4
5 The 'today' view is available on this page, listing all the timers active
6 today, as well as any other pertinent information for daily use.
7 """
8
9 use KlepsidraWeb, :live_view
10 import LiveToast
11 alias Klepsidra.TimeTracking
12 alias Klepsidra.TimeTracking.Timer
13 alias Klepsidra.TimeTracking.TimeUnits, as: Units
14
15 @impl true
16 0 def mount(_params, _session, socket) do
17 0 current_datetime_stamp = get_current_datetime_stamp()
18 0 aggregate_duration = get_aggregate_duration_for_date(current_datetime_stamp)
19
20 0 human_readable_duration =
21 Timer.format_human_readable_duration(aggregate_duration, [
22 :hour_increment,
23 :minute_increment
24 ])
25
26 0 open_timer_count = TimeTracking.get_open_timer_count()
27 0 closed_timer_count = TimeTracking.get_closed_timer_count_for_date(current_datetime_stamp)
28 0 today = format_date(current_datetime_stamp)
29
30 0 socket =
31 socket
32 |> assign(:today, today)
33 |> assign(
34 aggregate_duration: aggregate_duration,
35 human_readable_duration: human_readable_duration,
36 open_timer_count: open_timer_count,
37 closed_timer_count: closed_timer_count
38 )
39 |> stream(:open_timers, TimeTracking.get_all_open_timers())
40 |> stream(:closed_timers, TimeTracking.get_closed_timers_for_date(current_datetime_stamp))
41
42 {:ok, socket}
43 end
44
45 @impl true
46 0 def handle_params(params, _url, socket) do
47 0 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
48 end
49
50 defp apply_action(socket, :new_timer, _params) do
51 socket
52 |> assign(:page_title, "Manual Timer")
53 0 |> assign(:timer, %Timer{})
54 end
55
56 defp apply_action(socket, :show_timer, _params) do
57 socket
58 |> assign(:page_title, "Manual Timer")
59 0 |> assign(:timer, %Timer{})
60 end
61
62 defp apply_action(socket, :edit_timer, %{"id" => id}) do
63 socket
64 |> assign(:page_title, "Edit Timer")
65 0 |> assign(:timer, TimeTracking.get_timer!(id))
66 end
67
68 defp apply_action(socket, :start_timer, _params) do
69 0 billing_duration_unit = Units.get_default_billing_increment()
70
71 socket
72 |> assign(:page_title, "Starting Timer")
73 |> assign(
74 duration_unit: "minute",
75 billing_duration_unit: billing_duration_unit
76 )
77 0 |> assign(:timer, %Timer{})
78 end
79
80 defp apply_action(socket, :stop_timer, %{"id" => id}) do
81 0 start_timestamp = TimeTracking.get_timer!(id).start_stamp
82 0 clocked_out = Timer.clock_out(start_timestamp, :minute)
83 0 billing_duration_unit = Units.get_default_billing_increment()
84
85 0 billing_duration =
86 Timer.calculate_timer_duration(
87 start_timestamp,
88 0 clocked_out.end_timestamp,
89 String.to_existing_atom(billing_duration_unit)
90 )
91
92 socket
93 |> assign(:page_title, "Clock out")
94 |> assign(
95 clocked_out: clocked_out,
96 duration_unit: "minute",
97 billing_duration: billing_duration,
98 billing_duration_unit: billing_duration_unit
99 )
100 0 |> assign(:timer, TimeTracking.get_timer!(id))
101 end
102
103 defp apply_action(socket, :index, _params) do
104 socket
105 |> assign(:page_title, "Activity Timers")
106 0 |> assign(:timer, nil)
107 end
108
109 defp apply_action(socket, :new_note, %{"id" => id} = _params) do
110 socket
111 |> assign(:page_title, "New note")
112 0 |> assign(:timer_id, id)
113 end
114
115 0 defp apply_action(socket, nil, _params), do: socket
116
117 @impl true
118 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_open_timer, timer}}, socket) do
119 {:noreply, handle_open_timer(socket, timer)}
120 end
121
122 @impl true
123 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_closed_timer, timer}}, socket) do
124 {:noreply, handle_closed_timer(socket, timer)}
125 end
126
127 @impl true
128 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_open_timer, timer}}, socket) do
129 {:noreply, handle_updated_timer(socket, timer)}
130 end
131
132 @impl true
133 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_closed_timer, timer}}, socket) do
134 {:noreply, handle_updated_timer(socket, timer)}
135 end
136
137 @impl true
138 0 def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_started, timer}}, socket) do
139 {:noreply, handle_started_timer(socket, timer)}
140 end
141
142 @impl true
143 0 def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_stopped, timer}}, socket) do
144 {:noreply, handle_closed_timer(socket, timer)}
145 end
146
147 @impl true
148 0 def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:saved_note, note}}, socket) do
149 {:noreply, handle_saved_note(socket, note)}
150 end
151
152 @impl true
153 0 def handle_event("delete-open-timer", %{"id" => id}, socket) do
154 0 timer = TimeTracking.get_timer!(id)
155 0 {:ok, _} = TimeTracking.delete_timer(timer)
156
157 0 socket =
158 socket
159 0 |> update(:open_timer_count, fn tc -> tc - 1 end)
160
161 {:noreply, handle_deleted_timer(socket, timer, :open_timers)}
162 end
163
164 @impl true
165 0 def handle_event("delete-closed-timer", %{"id" => id}, socket) do
166 0 timer = TimeTracking.get_timer!(id)
167 0 deleted_timer_duration = {timer.duration, timer.duration_time_unit}
168 0 {:ok, _} = TimeTracking.delete_timer(timer)
169
170 0 socket =
171 socket
172 |> update(
173 :aggregate_duration,
174 fn aggregate_duration ->
175 0 update_aggregate_duration(:subtraction, aggregate_duration, deleted_timer_duration)
176 end
177 )
178 |> update(:human_readable_duration, fn _human_readable_duration, assigns ->
179 0 update_human_readable_duration(assigns.aggregate_duration)
180 end)
181 0 |> update(:closed_timer_count, fn tc -> tc - 1 end)
182
183 {:noreply, handle_deleted_timer(socket, timer, :closed_timers)}
184 end
185
186 defp handle_started_timer(socket, timer) do
187 socket
188 |> stream_insert(:open_timers, timer, at: 0)
189 0 |> update(:open_timer_count, fn tc -> tc + 1 end)
190 0 |> put_toast(:info, "Timer started")
191 end
192
193 defp handle_open_timer(socket, timer) do
194 socket
195 |> stream_insert(:open_timers, timer)
196 0 |> update(:open_timer_count, fn tc -> tc + 1 end)
197 0 |> put_toast(:info, "Timer created successfully")
198 end
199
200 defp handle_closed_timer(socket, timer) do
201 0 closed_timer_duration = {timer.duration, timer.duration_time_unit}
202
203 socket
204 |> update(
205 :aggregate_duration,
206 fn aggregate_duration ->
207 0 update_aggregate_duration(:summation, aggregate_duration, closed_timer_duration)
208 end
209 )
210 |> update(:human_readable_duration, fn _human_readable_duration, assigns ->
211 0 update_human_readable_duration(assigns.aggregate_duration)
212 end)
213 |> stream_delete(:open_timers, timer)
214 0 |> update(:open_timer_count, fn tc -> tc - 1 end)
215 |> stream_insert(:closed_timers, timer, at: 0)
216 0 |> update(:closed_timer_count, fn tc -> tc + 1 end)
217 0 |> put_toast(:info, "Timer stopped")
218 end
219
220 defp handle_updated_timer(socket, timer) do
221 0 previous_start_stamp = socket.assigns.timer.start_stamp
222 0 previous_end_stamp = socket.assigns.timer.end_stamp
223 0 current_start_stamp = timer.start_stamp
224 0 current_end_stamp = timer.end_stamp
225
226 0 previous_timer_status =
227 0 if previous_start_stamp != "" && previous_end_stamp != "" && not is_nil(previous_end_stamp) do
228 :closed
229 else
230 :open
231 end
232
233 0 current_timer_status =
234 0 if current_start_stamp != "" && current_end_stamp != "" && not is_nil(current_end_stamp) do
235 :closed
236 else
237 :open
238 end
239
240 socket
241 |> handle_updated_timer_changes(timer, {previous_timer_status, current_timer_status})
242 |> update(:human_readable_duration, fn _human_readable_duration, assigns ->
243 0 update_human_readable_duration(assigns.aggregate_duration)
244 end)
245 0 |> put_toast(:info, "Timer updated successfully")
246 end
247
248 defp handle_updated_timer_changes(socket, timer, {:open, :open}) do
249 socket
250 |> stream_insert(:open_timers, timer)
251 0 |> update(:open_timer_count, fn tc -> tc + 1 end)
252 end
253
254 defp handle_updated_timer_changes(socket, timer, {:closed, :closed}) do
255 socket
256 |> stream_insert(:closed_timers, timer)
257 |> update(
258 :aggregate_duration,
259 fn aggregate_duration ->
260 0 update_aggregate_duration(
261 :subtraction,
262 aggregate_duration,
263 0 {socket.assigns.timer.duration, socket.assigns.timer.duration_time_unit}
264 )
265 end
266 )
267 0 |> update(
268 :aggregate_duration,
269 fn aggregate_duration ->
270 0 update_aggregate_duration(
271 :summation,
272 aggregate_duration,
273 0 {timer.duration, timer.duration_time_unit}
274 )
275 end
276 )
277 end
278
279 defp handle_updated_timer_changes(socket, timer, {:open, :closed}) do
280 socket
281 |> stream_delete(:open_timers, timer)
282 0 |> update(:open_timer_count, fn tc -> tc - 1 end)
283 |> stream_insert(:closed_timers, timer)
284 0 |> update(:closed_timer_count, fn tc -> tc + 1 end)
285 0 |> update(:aggregate_duration, fn aggregate_duration ->
286 0 update_aggregate_duration(
287 :summation,
288 aggregate_duration,
289 0 {timer.duration, timer.duration_time_unit}
290 )
291 end)
292 end
293
294 defp handle_updated_timer_changes(socket, timer, {:closed, :open}) do
295 socket
296 |> stream_delete(:closed_timers, timer)
297 0 |> update(:closed_timer_count, fn tc -> tc - 1 end)
298 |> stream_insert(:open_timers, timer, at: 0)
299 0 |> update(:open_timer_count, fn tc -> tc + 1 end)
300 0 |> update(
301 :aggregate_duration,
302 fn aggregate_duration ->
303 0 update_aggregate_duration(
304 :subtraction,
305 aggregate_duration,
306 0 {socket.assigns.timer.duration, socket.assigns.timer.duration_time_unit}
307 )
308 end
309 )
310 end
311
312 defp handle_deleted_timer(socket, timer, source_stream) do
313 socket
314 |> stream_delete(source_stream, timer)
315 0 |> put_toast(:info, "Timer deleted successfully")
316 end
317
318 defp handle_saved_note(socket, _note) do
319 socket
320 0 |> put_toast(:info, "Note created successfully")
321 end
322
323 @spec get_current_datetime_stamp() :: NaiveDateTime.t()
324 defp get_current_datetime_stamp() do
325 Timer.get_current_timestamp()
326 0 |> NaiveDateTime.beginning_of_day()
327 end
328
329 defp update_aggregate_duration(:summation, starting_aggregate_duration, new_timer_duration) do
330 0 durations_list =
331 [starting_aggregate_duration, Timer.convert_duration_to_base_time_unit(new_timer_duration)]
332
333 0 Timer.sum_base_unit_durations(durations_list)
334 end
335
336 defp update_aggregate_duration(
337 :subtraction,
338 starting_aggregate_duration,
339 deleted_timer_duration
340 ) do
341 0 Timer.subtract_base_unit_durations(
342 starting_aggregate_duration,
343 Timer.convert_duration_to_base_time_unit(deleted_timer_duration)
344 )
345 end
346
347 defp update_human_readable_duration(new_aggregate_duration) do
348 0 Timer.format_human_readable_duration(new_aggregate_duration)
349 end
350
351 @spec format_date(datetime_stamp :: NaiveDateTime.t()) :: binary()
352 defp format_date(datetime_stamp) do
353 0 case Timer.format_human_readable_date(datetime_stamp) do
354 0 {:ok, formatted_date} -> formatted_date
355 0 _ -> ""
356 end
357 end
358
359 defp get_aggregate_duration_for_date(datetime_stamp) do
360 datetime_stamp
361 |> Klepsidra.TimeTracking.get_closed_timer_durations_for_date()
362 |> Timer.convert_durations_to_base_time_unit()
363 0 |> Timer.sum_base_unit_durations()
364 end
365 end

lib/klepsidra_web/live/tag_live/form_component.ex

86.6
30
113
4
Line Hits Source
0 defmodule KlepsidraWeb.TagLive.FormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4 import LiveToast
5
6 alias Klepsidra.Categorisation
7
8 @impl true
9 def render(assigns) do
10 7 ~H"""
11 <div>
12 3 <.header>
13 3 <%= @title %>
14 </.header>
15
16 7 <.simple_form
17 7 for={@form}
18 id="tag-form"
19 7 phx-target={@myself}
20 phx-change="validate"
21 phx-submit="save"
22 >
23 7 <.input field={@form[:name]} type="text" label="Name" />
24 7 <.input field={@form[:colour]} type="color" label="Colour" />
25 7 <.input field={@form[:fg_colour]} type="color" label="Text colour" />
26 7 <.input field={@form[:description]} type="textarea" label="Description" />
27 7 <:actions>
28 7 <.button phx-disable-with="Saving...">Save</.button>
29 </:actions>
30 </.simple_form>
31 </div>
32 """
33 end
34
35 @impl true
36 3 def update(%{tag: tag} = assigns, socket) do
37 3 changeset = Categorisation.change_tag(tag)
38
39 {:ok,
40 socket
41 |> assign(assigns)
42 |> assign_form(changeset)}
43 end
44
45 @impl true
46 3 def handle_event("validate", %{"tag" => tag_params}, socket) do
47 3 changeset =
48 3 socket.assigns.tag
49 |> Categorisation.change_tag(tag_params)
50 |> Map.put(:action, :validate)
51
52 {:noreply, assign_form(socket, changeset)}
53 end
54
55 def handle_event("save", %{"tag" => tag_params}, socket) do
56 3 save_tag(socket, socket.assigns.action, tag_params)
57 end
58
59 2 defp save_tag(socket, :edit, tag_params) do
60 2 case Categorisation.update_tag(socket.assigns.tag, tag_params) do
61 {:ok, tag} ->
62 2 notify_parent({:saved, tag})
63
64 {:noreply,
65 socket
66 |> put_toast(:info, "Tag updated successfully")
67 2 |> push_patch(to: socket.assigns.patch)}
68
69 0 {:error, %Ecto.Changeset{} = changeset} ->
70 {:noreply, assign_form(socket, changeset)}
71 end
72 end
73
74 0 defp save_tag(socket, :new, tag_params) do
75 1 case Categorisation.create_tag(tag_params) do
76 {:ok, tag} ->
77 0 notify_parent({:saved, tag})
78
79 {:noreply,
80 socket
81 |> put_toast(:info, "Tag created successfully")
82 0 |> push_patch(to: socket.assigns.patch)}
83
84 1 {:error, %Ecto.Changeset{} = changeset} ->
85 {:noreply, assign_form(socket, changeset)}
86 end
87 end
88
89 defp assign_form(socket, %Ecto.Changeset{} = changeset) do
90 7 assign(socket, :form, to_form(changeset))
91 end
92
93 2 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
94 end

lib/klepsidra_web/live/tag_live/index.ex

100.0
12
54
0
Line Hits Source
0 defmodule KlepsidraWeb.TagLive.Index do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4 import LiveToast
5
6 alias Klepsidra.Categorisation
7 alias Klepsidra.Categorisation.Tag
8
9 # alias KlepsidraWeb.Live.TagLive.SearchFormComponent
10
11 @impl true
12 8 def mount(_params, _session, socket) do
13 8 socket =
14 assign(socket,
15 search_phrase: "",
16 filtered_tags: [],
17 matches: []
18 )
19
20 {:ok, stream(socket, :tags, Categorisation.list_tags())}
21 end
22
23 @impl true
24 11 def handle_params(params, _url, socket) do
25 11 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
26 end
27
28 @impl true
29 1 def handle_event("delete", %{"id" => id}, socket) do
30 1 tag = Categorisation.get_tag!(id)
31 1 {:ok, _} = Categorisation.delete_tag(tag)
32
33 {:noreply, handle_deleted_tag(socket, tag, :tags)}
34 end
35
36 defp apply_action(socket, :edit, %{"id" => id}) do
37 socket
38 |> assign(:page_title, "Edit tag")
39 1 |> assign(:tag, Categorisation.get_tag!(id))
40 end
41
42 defp apply_action(socket, :new, _params) do
43 socket
44 |> assign(:page_title, "New tag")
45 1 |> assign(:tag, %Tag{})
46 end
47
48 defp apply_action(socket, :index, _params) do
49 socket
50 |> assign(:page_title, "Tags")
51 9 |> assign(:tag, nil)
52 end
53
54 @impl true
55 1 def handle_info({KlepsidraWeb.TagLive.FormComponent, {:saved, tag}}, socket) do
56 {:noreply, stream_insert(socket, :tags, tag)}
57 end
58
59 defp handle_deleted_tag(socket, tag, source_stream) do
60 socket
61 |> stream_delete(source_stream, tag)
62 1 |> put_toast(:info, "Tag deleted successfully")
63 end
64 end

lib/klepsidra_web/live/tag_live/show.ex

100.0
6
23
0
Line Hits Source
0 defmodule KlepsidraWeb.TagLive.Show do
1 use KlepsidraWeb, :live_view
2
3 @moduledoc false
4
5 alias Klepsidra.Categorisation
6
7 @impl true
8 4 def mount(_params, _session, socket) do
9 {:ok, socket}
10 end
11
12 @impl true
13 6 def handle_params(%{"id" => id}, _, socket) do
14 {:noreply,
15 socket
16 6 |> assign(:page_title, page_title(socket.assigns.live_action))
17 |> assign(:tag, Categorisation.get_tag!(id))}
18 end
19
20 @impl true
21 1 def handle_info({KlepsidraWeb.TagLive.FormComponent, {:saved, _tag}}, socket) do
22 {:noreply, socket}
23 end
24
25 5 defp page_title(:show), do: "Show Tag"
26 1 defp page_title(:edit), do: "Edit Tag"
27 end

lib/klepsidra_web/live/tag_live/tag_utilities.ex

8.0
25
22
23
Line Hits Source
0 defmodule KlepsidraWeb.TagLive.TagUtilities do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4 alias Klepsidra.Categorisation
5 alias Klepsidra.Categorisation.Tag
6 alias Klepsidra.DynamicCSS
7
8 @doc """
9 Format tag list to label/value map usable by `live_select` component.
10 """
11 @spec tag_options_for_live_select(tag_list :: [Tag.t(), ...]) :: [map, ...]
12 def tag_options_for_live_select(tag_list) when is_list(tag_list) do
13 tag_list
14 0 |> Enum.map(fn tag ->
15 0 %{
16 0 label: tag.name,
17 0 value: tag.id,
18 0 description: tag.description,
19 0 tag_class: "tag-#{DynamicCSS.convert_tag_name_to_class(tag.name)}",
20 0 bg_colour: tag.colour || "#94a3b8",
21 0 fg_colour: tag.fg_colour || "#fff"
22 }
23 end)
24 end
25
26 @doc """
27 Handles creation of new freeform tags, and their immediate selection as
28 a chosen tag for the entity.
29 """
30 @spec handle_free_tagging(
31 socket :: Phoenix.LiveView.Socket.t(),
32 tag_search_phrase :: String.t(),
33 free_tag_length :: integer(),
34 tag_select_id :: String.t(),
35 tag_colour :: {String.t(), String.t()},
36 options :: keyword()
37 ) :: Phoenix.LiveView.Socket.t()
38 def handle_free_tagging(
39 socket,
40 tag_search_phrase,
41 free_tag_length,
42 tag_select_id,
43 tag_colour,
44 0 options \\ []
45 )
46
47 def handle_free_tagging(
48 socket,
49 _tag_search_phrase,
50 free_tag_length,
51 _tag_select_id,
52 _tag_colour,
53 _options
54 )
55 when free_tag_length <= 2,
56 0 do: socket
57
58 def handle_free_tagging(
59 socket,
60 tag_search_phrase,
61 _free_tag_length,
62 tag_select_id,
63 {bg_colour, fg_colour},
64 _options
65 ) do
66 0 tag =
67 Categorisation.create_or_find_tag(%{
68 name: tag_search_phrase,
69 colour: bg_colour,
70 fg_colour: fg_colour
71 })
72
73 0 tags_applied = [tag.id | socket.assigns.selected_tag_queue]
74
75 0 generate_tag_options(
76 socket,
77 0 socket.assigns.selected_tag_queue,
78 tags_applied,
79 tag_select_id
80 )
81
82 0 send_update(LiveSelect.Component,
83 id: tag_select_id,
84 options: []
85 )
86
87 socket
88 0 |> assign(
89 tag_search_phrase: nil,
90 possible_free_tag_entered: false
91 )
92 end
93
94 @doc """
95 Takes list of tag IDs, returning full, tag-name sorted, HTML option list
96 for `live_select` component.
97 """
98 @spec generate_tag_options(
99 socket :: Phoenix.LiveView.Socket.t(),
100 previous_tag_list :: [Ecto.UUID.t(), ...] | [],
101 accumulated_tag_list :: [Ecto.UUID.t(), ...] | [],
102 tag_select_id :: String.t(),
103 options :: keyword()
104 ) :: any()
105 def generate_tag_options(
106 socket,
107 previous_tag_list,
108 accumulated_tag_list,
109 tag_select_id,
110 11 options \\ []
111 )
112
113 def generate_tag_options(
114 %{assigns: %{selected_tags: _selected_tags, selected_tag_queue: _selected_tag_queue}} =
115 socket,
116 previous_tag_list,
117 previous_tag_list,
118 _tag_select_id,
119 _options
120 ),
121 0 do: socket
122
123 def generate_tag_options(
124 socket,
125 previous_tag_list,
126 previous_tag_list,
127 _tag_select_id,
128 _options
129 ),
130 11 do: assign(socket, selected_tags: [], selected_tag_queue: [])
131
132 def generate_tag_options(
133 socket,
134 _previous_tag_list,
135 accumulated_tag_list,
136 tag_select_id,
137 options
138 ) do
139 0 parent_tag_select_id = Keyword.get(options, :parent_tag_select_id, nil)
140
141 0 tag_options =
142 accumulated_tag_list
143 |> Categorisation.get_tags!()
144 |> tag_options_for_live_select()
145
146 0 send_update(LiveSelect.Component, id: tag_select_id, value: tag_options)
147
148 0 parent_tag_select_id &&
149 0 send_update(LiveSelect.Component, id: parent_tag_select_id, value: tag_options)
150
151 0 assign(socket, selected_tags: tag_options, selected_tag_queue: accumulated_tag_list)
152 end
153 end

lib/klepsidra_web/live/timer_live/automated_timer.ex

0.0
145
0
145
Line Hits Source
0 defmodule KlepsidraWeb.TimerLive.AutomatedTimer do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4
5 alias Klepsidra.TimeTracking
6 alias Klepsidra.TimeTracking.Timer
7 alias Klepsidra.TimeTracking.TimeUnits, as: Units
8 alias Klepsidra.Projects.Project
9 alias Klepsidra.BusinessPartners.BusinessPartner
10 # alias Klepsidra.TimeTracking.ActivityType
11 alias Klepsidra.Categorisation
12 alias Klepsidra.Categorisation.Tag
13 alias KlepsidraWeb.TagLive.TagUtilities
14 alias Klepsidra.DynamicCSS
15
16 @tag_search_live_component_id "timer_ls_tag_search_live_select_component"
17
18 @impl true
19 def render(assigns) do
20 0 ~H"""
21 <div>
22 0 <.header>
23 0 <%= @title %>
24 </.header>
25
26 0 <.simple_form
27 0 for={@form}
28 id="timer-form"
29 0 phx-target={@myself}
30 phx-change="validate"
31 phx-window-keyup="key_up"
32 phx-submit="save"
33 >
34 0 <div :if={@invocation_context == :start_timer}>
35 0 <.input
36 0 field={@form[:description]}
37 type="text"
38 label="Description"
39 placeholder="What are you working on?"
40 autocomplete="off"
41 />
42 </div>
43
44 0 <div :if={@invocation_context == :stop_timer}>
45 <div class="hidden">
46 0 <.input field={@form[:start_stamp]} type="datetime-local" label="Start time" readonly />
47
48 0 <.input field={@form[:end_stamp]} type="datetime-local" label="End time" readonly />
49 </div>
50
51 0 <.input field={@form[:duration]} type="text" label="Duration" readonly />
52
53 0 <.input
54 0 field={@form[:duration_time_unit]}
55 type="select"
56 label="Duration time increment"
57 options={Units.construct_duration_unit_options_list(use_primitives?: true)}
58 />
59 </div>
60
61 0 <div id="tag-selector" class={"flex #{if @selected_tag_queue != [], do: "gap-2"}"}>
62 0 <div
63 id="tag-selector__live-select"
64 phx-mounted={JS.add_class("hidden", to: "#timer_ls_tag_search_text_input")}
65 >
66 0 <.live_select
67 0 field={@form[:ls_tag_search]}
68 mode={:tags}
69 label=""
70 options={[]}
71 placeholder="Add tag"
72 debounce={80}
73 clear_tag_button_class="cursor-pointer px-1 rounded-r-md"
74 dropdown_extra_class="bg-white max-h-48 overflow-y-scroll"
75 tag_class="bg-slate-400 text-white flex rounded-md text-sm font-semibold"
76 tags_container_class="flex flex-wrap gap-2"
77 container_extra_class="rounded border border-none"
78 update_min_len={1}
79 user_defined_options="true"
80 0 value={@selected_tags}
81 phx-blur="ls_tag_search_blur"
82 0 phx-target={@myself}
83 >
84 0 <:option :let={option}>
85 0 <div class="flex" title={option.description}>
86 0 <%= option.label %>
87 </div>
88 </:option>
89 0 <:tag :let={option}>
90 0 <div class={"#{option.tag_class} py-1.5 px-3 rounded-l-md"} title={option.description}>
91 0 <.link navigate={~p"/tags/#{option.value}"}>
92 0 <%= option.label %>
93 </.link>
94 </div>
95 </:tag>
96 </.live_select>
97 </div>
98
99 <div
100 id="tag-selector__colour-select"
101 class="tag-colour-picker hidden w-10 overflow-hidden self-end shrink-0"
102 >
103 0 <.input field={@form[:bg_colour]} type="color" value={elem(@new_tag_colour, 0)} />
104 </div>
105
106 0 <.button
107 id="tag-selector__add-button"
108 class="flex-none flex-grow-0 h-fit self-end [&&]:bg-violet-50 [&&]:text-indigo-900 [&&]:py-1 rounded-md"
109 type="button"
110 phx-click={enable_tag_selector()}
111 >
112 Add tag +
113 </.button>
114 </div>
115
116 0 <div :if={@invocation_context == :stop_timer}>
117 0 <.input
118 0 field={@form[:description]}
119 type="textarea"
120 label="Description"
121 placeholder="What did you work on?"
122 />
123 </div>
124
125 0 <.input field={@form[:project_id]} type="select" label="Project" options={@projects} />
126
127 0 <.input
128 0 field={@form[:business_partner_id]}
129 type="select"
130 label="Customer"
131 placeholder="Customer"
132 0 options={@business_partners}
133 0 required={@billable_activity?}
134 />
135
136 0 <.input field={@form[:billable]} type="checkbox" label="Billable?" />
137
138 0 <div class={unless @billable_activity? && @invocation_context == :stop_timer, do: "hidden"}>
139 0 <.input field={@form[:billing_duration]} type="text" label="Billable duration" readonly />
140
141 0 <.input
142 0 field={@form[:billing_duration_time_unit]}
143 type="select"
144 label="Billable time increment"
145 options={Units.construct_duration_unit_options_list()}
146 />
147 </div>
148
149 0 <:actions>
150 0 <.button phx-disable-with="Saving...">
151 0 <%= if @invocation_context == :start_timer, do: "Start timer", else: "Save" %>
152 </.button>
153 </:actions>
154 </.simple_form>
155 </div>
156 """
157 end
158
159 @impl true
160 @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
161 0 def update(%{timer: timer} = assigns, socket) do
162 0 timer =
163 0 case timer.id do
164 0 nil -> timer |> Klepsidra.Repo.preload(:tags)
165 0 _ -> TimeTracking.get_timer!(timer.id) |> Klepsidra.Repo.preload(:tags)
166 end
167
168 0 timer_changes =
169 0 case assigns.invocation_context do
170 :stop_timer ->
171 0 start_stamp = timer.start_stamp
172 0 end_stamp = Timer.get_current_timestamp() |> Timer.convert_naivedatetime_to_html!()
173 0 duration_time_unit = timer.duration_time_unit
174 0 billing_duration_time_unit = timer.billing_duration_time_unit
175
176 0 duration =
177 Timer.assign_timer_duration(
178 %{
179 "start_stamp" => start_stamp,
180 "end_stamp" => end_stamp,
181 "duration_time_unit" => duration_time_unit
182 },
183 "duration_time_unit"
184 )
185
186 0 billable = Timer.read_checkbox(timer.billable)
187
188 0 billing_duration =
189 0 if billable do
190 0 Timer.assign_timer_duration(
191 %{
192 "start_stamp" => start_stamp,
193 "end_stamp" => end_stamp,
194 "billing_duration_time_unit" => billing_duration_time_unit
195 },
196 "billing_duration_time_unit"
197 )
198 else
199 0
200 end
201
202 0 %{
203 "end_stamp" => end_stamp,
204 "duration" => duration,
205 "billing_duration" => billing_duration
206 }
207
208 _ ->
209 0 %{}
210 end
211
212 0 changeset = TimeTracking.change_timer(timer, timer_changes)
213
214 0 socket =
215 socket
216 |> TagUtilities.generate_tag_options(
217 [],
218 0 Enum.map(timer.tags, fn tag -> tag.id end),
219 @tag_search_live_component_id
220 )
221 |> Phx.Live.Head.push(
222 "style[id*=dynamic-style-block]",
223 :dynamic,
224 "style_declarations",
225 0 DynamicCSS.generate_tag_styles(timer.tags)
226 )
227
228 0 socket =
229 socket
230 |> assign(assigns)
231 |> assign_form(changeset)
232 |> assign(
233 0 billable_activity?: assigns.timer.billable,
234 new_tag_colour: {"#94a3b8", "#fff"}
235 )
236 |> assign_business_partner()
237 |> assign_project()
238
239 {:ok, socket}
240 end
241
242 @impl true
243 0 def handle_event(
244 "validate",
245 %{"_target" => ["timer", "duration_time_unit"], "timer" => timer_params},
246 socket
247 ) do
248 0 changeset =
249 0 socket.assigns.timer
250 |> TimeTracking.change_timer(%{
251 timer_params
252 | "duration" => Timer.assign_timer_duration(timer_params, "duration_time_unit")
253 })
254 |> Map.put(:action, :validate)
255
256 {:noreply, assign_form(socket, changeset)}
257 end
258
259 @impl true
260 0 def handle_event(
261 "validate",
262 %{"_target" => ["timer", "billing_duration_time_unit"], "timer" => timer_params},
263 socket
264 ) do
265 0 billable = Timer.read_checkbox(timer_params["billable"])
266
267 0 billing_duration =
268 0 if billable do
269 0 Timer.assign_timer_duration(timer_params, "billing_duration_time_unit")
270 else
271 0
272 end
273
274 0 changeset =
275 0 socket.assigns.timer
276 |> TimeTracking.change_timer(%{
277 timer_params
278 | "billing_duration" => billing_duration
279 })
280 |> Map.put(:action, :validate)
281
282 {:noreply, assign_form(socket, changeset)}
283 end
284
285 @impl true
286 0 def handle_event(
287 "validate",
288 %{"_target" => ["timer", "billable"], "timer" => timer_params},
289 socket
290 ) do
291 0 billable = Timer.read_checkbox(timer_params["billable"])
292
293 0 billing_duration =
294 0 if billable do
295 0 Timer.assign_timer_duration(timer_params, "billing_duration_time_unit")
296 else
297 0
298 end
299
300 0 changeset =
301 0 socket.assigns.timer
302 |> TimeTracking.change_timer(%{
303 timer_params
304 | "billing_duration" => billing_duration
305 })
306 |> Map.put(:action, :validate)
307
308 0 socket =
309 socket
310 |> assign(billable_activity?: billable)
311
312 {:noreply, assign_form(socket, changeset)}
313 end
314
315 0 def handle_event(
316 "validate",
317 %{"_target" => ["timer", "ls_tag_search"], "timer" => %{"ls_tag_search" => tags_applied}},
318 socket
319 ) do
320 0 Tag.handle_tag_list_changes(
321 0 socket.assigns.selected_tag_queue,
322 tags_applied,
323 0 socket.assigns.timer.id,
324 &Categorisation.add_timer_tag(&1, &2),
325 &Categorisation.delete_timer_tag(&1, &2)
326 )
327
328 0 socket =
329 TagUtilities.generate_tag_options(
330 socket,
331 0 socket.assigns.selected_tag_queue,
332 tags_applied,
333 @tag_search_live_component_id,
334 0 parent_tag_select_id: socket.assigns.parent_tag_select_id
335 )
336 |> Phx.Live.Head.push(
337 "style[id*=dynamic-style-block]",
338 :dynamic,
339 "style_declarations",
340 DynamicCSS.generate_tag_styles(tags_applied)
341 )
342
343 0 socket =
344 socket
345 |> assign(
346 tag_search_phrase: nil,
347 possible_free_tag_entered: false
348 )
349
350 {:noreply, socket}
351 end
352
353 @doc """
354 Validate event which fires only once the last of the tags has been cleared
355 from a `live_select` component.
356 """
357 0 def handle_event(
358 "validate",
359 %{
360 "_target" => ["timer", "ls_tag_search_empty_selection"],
361 "timer" => %{"ls_tag_search_empty_selection" => ""}
362 },
363 socket
364 ) do
365 0 Tag.handle_tag_list_changes(
366 0 socket.assigns.selected_tag_queue,
367 [],
368 0 socket.assigns.timer.id,
369 &Categorisation.add_timer_tag(&1, &2),
370 &Categorisation.delete_timer_tag(&1, &2)
371 )
372
373 0 socket.assigns.parent_tag_select_id &&
374 0 send_update(LiveSelect.Component, id: socket.assigns.parent_tag_select_id, value: [])
375
376 0 socket =
377 socket
378 |> assign(
379 tag_search_phrase: nil,
380 possible_free_tag_entered: false
381 )
382
383 {:noreply, socket}
384 end
385
386 0 def handle_event(
387 "validate",
388 %{
389 "_target" => ["timer", "bg_colour"],
390 "timer" => %{
391 "bg_colour" => bg_colour
392 }
393 },
394 socket
395 ) do
396 0 fg_colour =
397 case ColorContrast.calc_contrast(bg_colour) do
398 0 {:ok, fg_colour} -> fg_colour
399 0 {:error, _} -> "#fff"
400 end
401
402 0 socket =
403 socket
404 |> assign(new_tag_colour: {bg_colour, fg_colour})
405
406 {:noreply, socket}
407 end
408
409 @impl true
410 0 def handle_event("validate", %{"timer" => timer_params}, socket) do
411 0 changeset =
412 0 socket.assigns.timer
413 |> TimeTracking.change_timer(timer_params)
414 |> Map.put(:action, :validate)
415
416 {:noreply, assign_form(socket, changeset)}
417 end
418
419 def handle_event("save", %{"timer" => timer_params}, socket) do
420 0 save_timer(socket, socket.assigns.action, timer_params)
421 end
422
423 0 def handle_event(
424 "live_select_change",
425 %{
426 "field" => "timer_ls_tag_search",
427 "id" => live_select_id,
428 "text" => tag_search_phrase
429 },
430 socket
431 ) do
432 0 tag_search_results =
433 Categorisation.search_tags_by_name_content(tag_search_phrase)
434 |> TagUtilities.tag_options_for_live_select()
435
436 0 send_update(LiveSelect.Component, id: live_select_id, options: tag_search_results)
437
438 0 socket =
439 socket
440 |> assign(
441 tag_search_phrase: tag_search_phrase,
442 possible_free_tag_entered: true
443 )
444
445 {:noreply, socket}
446 end
447
448 0 def handle_event(
449 "ls_tag_search_blur",
450 %{"id" => @tag_search_live_component_id},
451 socket
452 ) do
453 0 socket =
454 socket
455 |> assign(
456 tag_search_phrase: nil,
457 possible_free_tag_entered: false
458 )
459
460 {:noreply, socket}
461 end
462
463 0 def handle_event(
464 "key_up",
465 %{"key" => "Enter"},
466 %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} =
467 socket
468 ) do
469 0 socket =
470 TagUtilities.handle_free_tagging(
471 socket,
472 tag_search_phrase,
473 String.length(tag_search_phrase),
474 @tag_search_live_component_id,
475 0 socket.assigns.new_tag_colour
476 )
477
478 {:noreply, socket}
479 end
480
481 0 def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket}
482
483 0 defp save_timer(socket, :start_timer, timer_params) do
484 0 timer_params =
485 Map.merge(timer_params, %{
486 "start_stamp" =>
487 Timer.get_current_timestamp()
488 |> Timer.convert_naivedatetime_to_html!(),
489 "duration" => "0",
490 "duration_time_unit" => "minute",
491 "billing_duration" => "0",
492 "billing_duration_time_unit" => Units.get_default_billing_increment()
493 })
494
495 0 case TimeTracking.create_timer(timer_params) do
496 {:ok, timer} ->
497 0 timer = TimeTracking.get_formatted_timer_record!(timer.id)
498
499 0 Tag.handle_tag_list_changes(
500 [],
501 0 socket.assigns.selected_tag_queue,
502 0 timer.id,
503 &Categorisation.add_timer_tag(&1, &2),
504 &Categorisation.delete_timer_tag(&1, &2)
505 )
506
507 0 notify_parent({:timer_started, timer})
508
509 {:noreply,
510 socket
511 0 |> push_patch(to: socket.assigns.patch)}
512
513 0 {:error, %Ecto.Changeset{} = changeset} ->
514 {:noreply, assign_form(socket, changeset)}
515 end
516 end
517
518 0 defp save_timer(socket, :stop_timer, timer_params) do
519 0 case TimeTracking.update_timer(socket.assigns.timer, timer_params) do
520 {:ok, timer} ->
521 0 timer = TimeTracking.get_formatted_timer_record!(timer.id)
522 0 notify_parent({:timer_stopped, timer})
523
524 {:noreply,
525 socket
526 0 |> push_patch(to: socket.assigns.patch)}
527
528 0 {:error, %Ecto.Changeset{} = changeset} ->
529 {:noreply, assign_form(socket, changeset)}
530 end
531 end
532
533 defp assign_form(socket, %Ecto.Changeset{} = changeset) do
534 0 assign(socket, :form, to_form(changeset))
535 end
536
537 @spec assign_project(Phoenix.LiveView.Socket.t()) ::
538 Phoenix.LiveView.Socket.t()
539 defp assign_project(socket) do
540 0 projects = Project.populate_projects_list()
541
542 0 assign(socket, projects: projects)
543 end
544
545 @spec assign_business_partner(Phoenix.LiveView.Socket.t()) ::
546 Phoenix.LiveView.Socket.t()
547 defp assign_business_partner(socket) do
548 0 business_partners = BusinessPartner.populate_customers_list()
549
550 0 assign(socket, business_partners: business_partners)
551 end
552
553 0 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
554
555 defp enable_tag_selector() do
556 JS.remove_class("hidden", to: "#timer_ls_tag_search_text_input")
557 |> JS.remove_class("hidden", to: "#tag-selector__colour-select")
558 |> JS.add_class("hidden", to: "#tag-selector__add-button")
559 |> JS.add_class("gap-2", to: "#tag-selector")
560 |> JS.add_class("flex-auto", to: "#tag-selector__live-select")
561 0 |> JS.focus(to: "#timer_ls_tag_search_text_input")
562 end
563 end

lib/klepsidra_web/live/timer_live/form_component.ex

42.4
153
121
88
Line Hits Source
0 defmodule KlepsidraWeb.TimerLive.FormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4
5 alias Klepsidra.TimeTracking
6 alias Klepsidra.TimeTracking.Timer
7 alias Klepsidra.TimeTracking.TimeUnits, as: Units
8 alias Klepsidra.Projects.Project
9 alias Klepsidra.BusinessPartners.BusinessPartner
10 alias Klepsidra.TimeTracking.ActivityType
11 alias Klepsidra.Categorisation
12 alias Klepsidra.Categorisation.Tag
13 alias KlepsidraWeb.TagLive.TagUtilities
14 alias Klepsidra.DynamicCSS
15
16 @tag_search_live_component_id "timer_ls_tag_search_live_select_component"
17
18 @impl true
19 def render(assigns) do
20 2 ~H"""
21 <div>
22 2 <.header>
23 2 <%= @title %>
24 </.header>
25
26 2 <.simple_form
27 2 for={@form}
28 id="timer-form"
29 2 phx-target={@myself}
30 phx-change="validate"
31 phx-window-keyup="key_up"
32 phx-submit="save"
33 >
34 2 <.input field={@form[:start_stamp]} type="datetime-local" label="Start time" />
35 2 <.input field={@form[:end_stamp]} type="datetime-local" label="End time" />
36
37 2 <.input field={@form[:duration]} type="text" label="Duration" readonly />
38
39 2 <.input
40 2 field={@form[:duration_time_unit]}
41 type="select"
42 label="Duration time increment"
43 options={Units.construct_duration_unit_options_list(use_primitives?: true)}
44 />
45
46 2 <div id="tag-selector" class={"flex #{if @selected_tag_queue != [], do: "gap-2"}"}>
47 2 <div
48 id="tag-selector__live-select"
49 phx-mounted={JS.add_class("hidden", to: "#timer_ls_tag_search_text_input")}
50 >
51 2 <.live_select
52 2 field={@form[:ls_tag_search]}
53 mode={:tags}
54 label=""
55 options={[]}
56 placeholder="Add tag"
57 debounce={80}
58 clear_tag_button_class="cursor-pointer px-1 rounded-r-md"
59 dropdown_extra_class="bg-white max-h-48 overflow-y-scroll"
60 tag_class="bg-slate-400 text-white flex rounded-md text-sm font-semibold"
61 tags_container_class="flex flex-wrap gap-2"
62 container_extra_class="rounded border border-none"
63 update_min_len={1}
64 user_defined_options="true"
65 2 value={@selected_tags}
66 phx-blur="ls_tag_search_blur"
67 2 phx-target={@myself}
68 >
69 0 <:option :let={option}>
70 0 <div class="flex" title={option.description}>
71 0 <%= option.label %>
72 </div>
73 </:option>
74 0 <:tag :let={option}>
75 0 <div class={"#{option.tag_class} py-1.5 px-3 rounded-l-md"} title={option.description}>
76 0 <.link navigate={~p"/tags/#{option.value}"}>
77 0 <%= option.label %>
78 </.link>
79 </div>
80 </:tag>
81 </.live_select>
82 </div>
83
84 <div
85 id="tag-selector__colour-select"
86 class="tag-colour-picker hidden w-10 overflow-hidden self-end shrink-0"
87 >
88 2 <.input field={@form[:bg_colour]} type="color" value={elem(@new_tag_colour, 0)} />
89 </div>
90
91 2 <.button
92 id="tag-selector__add-button"
93 class="add-tag-button flex-none flex-grow-0 h-fit self-end [&&]:bg-violet-50 [&&]:text-indigo-900 [&&]:py-1 rounded-md"
94 type="button"
95 phx-click={enable_tag_selector()}
96 >
97 Add tag +
98 </.button>
99 </div>
100
101 2 <.input field={@form[:description]} type="textarea" label="Description" />
102
103 2 <.input field={@form[:project_id]} type="select" label="Project" options={@projects} />
104
105 2 <.input
106 2 field={@form[:business_partner_id]}
107 type="select"
108 label="Customer"
109 2 options={@business_partners}
110 2 required={@billable_activity?}
111 />
112
113 2 <.input field={@form[:billable]} type="checkbox" label="Billable?" />
114
115 2 <div class={unless @billable_activity?, do: "hidden"}>
116 2 <.input field={@form[:billing_duration]} type="text" label="Billable duration" readonly />
117
118 2 <.input
119 2 field={@form[:billing_duration_time_unit]}
120 type="select"
121 label="Billable time increment"
122 options={Units.construct_duration_unit_options_list()}
123 />
124
125 2 <.input
126 2 field={@form[:activity_type_id]}
127 type="select"
128 label="Activity type"
129 2 options={@activity_types}
130 />
131
132 2 <.input
133 2 field={@form[:billing_rate]}
134 type="number"
135 label="Hourly billing rate"
136 min="0"
137 step="0.01"
138 />
139 </div>
140
141 2 <:actions>
142 2 <.button phx-disable-with="Saving...">Save</.button>
143 </:actions>
144 </.simple_form>
145 </div>
146 """
147 end
148
149 @impl true
150 @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
151 2 def update(%{timer: timer} = assigns, socket) do
152 2 timer_changes =
153 2 case assigns.invocation_context do
154 :new_timer ->
155 1 %{
156 "duration" => "0",
157 "duration_time_unit" => "minute",
158 "billing_duration" => "0",
159 "billing_duration_time_unit" => Units.get_default_billing_increment()
160 }
161
162 _ ->
163 1 %{}
164 end
165
166 2 timer = timer |> Klepsidra.Repo.preload(:tags)
167
168 2 changeset = TimeTracking.change_timer(timer, timer_changes)
169
170 2 socket =
171 socket
172 |> assign(assigns)
173 |> TagUtilities.generate_tag_options(
174 [],
175 2 Enum.map(timer.tags, fn tag -> tag.id end),
176 @tag_search_live_component_id
177 )
178 |> Phx.Live.Head.push(
179 "style[id*=dynamic-style-block]",
180 :dynamic,
181 "style_declarations",
182 2 DynamicCSS.generate_tag_styles(timer.tags)
183 )
184 |> assign(
185 2 billable_activity?: timer.billable,
186 new_tag_colour: {"#94a3b8", "#fff"}
187 )
188 |> assign_project()
189 |> assign_business_partner()
190 |> assign_activity_type()
191 |> assign_form(changeset)
192
193 {:ok, socket}
194 end
195
196 @impl true
197 0 def handle_event(
198 "validate",
199 %{"_target" => ["timer", "start_stamp"], "timer" => timer_params},
200 socket
201 ) do
202 0 duration = Timer.assign_timer_duration(timer_params, "duration_time_unit")
203 0 billable = Timer.read_checkbox(timer_params["billable"])
204
205 0 billing_duration =
206 0 if billable do
207 0 Timer.assign_timer_duration(timer_params, "billing_duration_time_unit")
208 else
209 0
210 end
211
212 0 changeset =
213 0 socket.assigns.timer
214 |> TimeTracking.change_timer(%{
215 timer_params
216 | "duration" => duration,
217 "billing_duration" => billing_duration
218 })
219 |> Map.put(:action, :validate)
220
221 {:noreply, assign_form(socket, changeset)}
222 end
223
224 0 def handle_event(
225 "validate",
226 %{"_target" => ["timer", "end_stamp"], "timer" => timer_params},
227 socket
228 ) do
229 0 duration = Timer.assign_timer_duration(timer_params, "duration_time_unit")
230
231 0 billable = Timer.read_checkbox(timer_params["billable"])
232
233 0 billing_duration =
234 0 if billable do
235 0 Timer.assign_timer_duration(timer_params, "billing_duration_time_unit")
236 else
237 0
238 end
239
240 0 changeset =
241 0 socket.assigns.timer
242 |> TimeTracking.change_timer(%{
243 timer_params
244 | "duration" => duration,
245 "billing_duration" => billing_duration
246 })
247 |> Map.put(:action, :validate)
248
249 {:noreply, assign_form(socket, changeset)}
250 end
251
252 0 def handle_event(
253 "validate",
254 %{"_target" => ["timer", "duration_time_unit"], "timer" => timer_params},
255 socket
256 ) do
257 0 changeset =
258 0 socket.assigns.timer
259 |> TimeTracking.change_timer(%{
260 timer_params
261 | "duration" => Timer.assign_timer_duration(timer_params, "duration_time_unit")
262 })
263 |> Map.put(:action, :validate)
264
265 {:noreply, assign_form(socket, changeset)}
266 end
267
268 0 def handle_event(
269 "validate",
270 %{"_target" => ["timer", "billing_duration_time_unit"], "timer" => timer_params},
271 socket
272 ) do
273 0 billable = Timer.read_checkbox(timer_params["billable"])
274
275 0 billing_duration =
276 0 if billable do
277 0 Timer.assign_timer_duration(timer_params, "billing_duration_time_unit")
278 else
279 0
280 end
281
282 0 changeset =
283 0 socket.assigns.timer
284 |> TimeTracking.change_timer(%{
285 timer_params
286 | "billing_duration" => billing_duration
287 })
288 |> Map.put(:action, :validate)
289
290 {:noreply, assign_form(socket, changeset)}
291 end
292
293 0 def handle_event(
294 "validate",
295 %{"_target" => ["timer", "billable"], "timer" => timer_params},
296 socket
297 ) do
298 0 billable = Timer.read_checkbox(timer_params["billable"])
299
300 0 billing_duration =
301 0 if billable do
302 0 Timer.assign_timer_duration(timer_params, "billing_duration_time_unit")
303 else
304 0
305 end
306
307 0 activity_type_id =
308 case billable do
309 0 true -> socket.assigns.timer.activity_type_id
310 0 false -> ""
311 end
312
313 0 changeset =
314 0 socket.assigns.timer
315 |> TimeTracking.change_timer(%{
316 timer_params
317 | "activity_type_id" => activity_type_id,
318 "billing_duration" => billing_duration
319 })
320 |> Map.put(:action, :validate)
321
322 0 socket =
323 socket
324 |> assign(billable_activity?: billable)
325 |> assign_activity_type()
326
327 {:noreply, assign_form(socket, changeset)}
328 end
329
330 0 def handle_event(
331 "validate",
332 %{"_target" => ["timer", "activity_type_id"], "timer" => timer_params},
333 socket
334 ) do
335 0 billable = Timer.read_checkbox(timer_params["billable"])
336
337 0 billing_rate =
338 0 if billable do
339 0 activity_type_id = timer_params["activity_type_id"]
340
341 0 Klepsidra.TimeTracking.get_activity_type!(activity_type_id).billing_rate
342 else
343 0
344 end
345
346 0 changeset =
347 0 socket.assigns.timer
348 |> TimeTracking.change_timer(%{
349 timer_params
350 | "billing_rate" => billing_rate
351 })
352 |> Map.put(:action, :validate)
353
354 {:noreply, assign_form(socket, changeset)}
355 end
356
357 0 def handle_event(
358 "validate",
359 %{"_target" => ["timer", "ls_tag_search"], "timer" => %{"ls_tag_search" => tags_applied}},
360 socket
361 ) do
362 0 Tag.handle_tag_list_changes(
363 0 socket.assigns.selected_tag_queue,
364 tags_applied,
365 0 socket.assigns.timer.id,
366 &Categorisation.add_timer_tag(&1, &2),
367 &Categorisation.delete_timer_tag(&1, &2)
368 )
369
370 0 socket =
371 TagUtilities.generate_tag_options(
372 socket,
373 0 socket.assigns.selected_tag_queue,
374 tags_applied,
375 @tag_search_live_component_id,
376 0 parent_tag_select_id: socket.assigns.parent_tag_select_id
377 )
378 |> Phx.Live.Head.push(
379 "style[id*=dynamic-style-block]",
380 :dynamic,
381 "style_declarations",
382 DynamicCSS.generate_tag_styles(tags_applied)
383 )
384
385 0 socket =
386 socket
387 |> assign(
388 tag_search_phrase: nil,
389 possible_free_tag_entered: false
390 )
391
392 {:noreply, socket}
393 end
394
395 @doc """
396 Validate event which fires only once the last of the tags has been cleared
397 from a `live_select` component.
398 """
399 0 def handle_event(
400 "validate",
401 %{
402 "_target" => ["timer", "ls_tag_search_empty_selection"],
403 "timer" => %{"ls_tag_search_empty_selection" => ""}
404 },
405 socket
406 ) do
407 0 Tag.handle_tag_list_changes(
408 0 socket.assigns.selected_tag_queue,
409 [],
410 0 socket.assigns.timer.id,
411 &Categorisation.add_timer_tag(&1, &2),
412 &Categorisation.delete_timer_tag(&1, &2)
413 )
414
415 0 socket.assigns.parent_tag_select_id &&
416 0 send_update(LiveSelect.Component, id: socket.assigns.parent_tag_select_id, value: [])
417
418 0 socket =
419 socket
420 |> assign(
421 tag_search_phrase: nil,
422 possible_free_tag_entered: false
423 )
424
425 {:noreply, socket}
426 end
427
428 0 def handle_event(
429 "validate",
430 %{
431 "_target" => ["timer", "bg_colour"],
432 "timer" => %{
433 "bg_colour" => bg_colour
434 }
435 },
436 socket
437 ) do
438 0 fg_colour =
439 case ColorContrast.calc_contrast(bg_colour) do
440 0 {:ok, fg_colour} -> fg_colour
441 0 {:error, _} -> "#fff"
442 end
443
444 0 socket =
445 socket
446 |> assign(new_tag_colour: {bg_colour, fg_colour})
447
448 {:noreply, socket}
449 end
450
451 0 def handle_event("validate", %{"timer" => timer_params}, socket) do
452 0 changeset =
453 0 socket.assigns.timer
454 |> TimeTracking.change_timer(timer_params)
455 |> Map.put(:action, :validate)
456
457 {:noreply, assign_form(socket, changeset)}
458 end
459
460 def handle_event("save", %{"timer" => timer_params}, socket) do
461 1 save_timer(socket, socket.assigns.action, timer_params)
462 end
463
464 0 def handle_event(
465 "live_select_change",
466 %{
467 "field" => "timer_ls_tag_search",
468 "id" => live_select_id,
469 "text" => tag_search_phrase
470 },
471 socket
472 ) do
473 0 tag_search_results =
474 Categorisation.search_tags_by_name_content(tag_search_phrase)
475 |> TagUtilities.tag_options_for_live_select()
476
477 0 send_update(LiveSelect.Component, id: live_select_id, options: tag_search_results)
478
479 0 socket =
480 socket
481 |> assign(
482 tag_search_phrase: tag_search_phrase,
483 possible_free_tag_entered: true
484 )
485
486 {:noreply, socket}
487 end
488
489 0 def handle_event(
490 "ls_tag_search_blur",
491 %{"id" => @tag_search_live_component_id},
492 socket
493 ) do
494 0 socket =
495 socket
496 |> assign(
497 tag_search_phrase: nil,
498 possible_free_tag_entered: false
499 )
500
501 {:noreply, socket}
502 end
503
504 0 def handle_event(
505 "key_up",
506 %{"key" => "Enter"},
507 %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} =
508 socket
509 ) do
510 0 socket =
511 TagUtilities.handle_free_tagging(
512 socket,
513 tag_search_phrase,
514 String.length(tag_search_phrase),
515 @tag_search_live_component_id,
516 0 socket.assigns.new_tag_colour
517 )
518
519 {:noreply, socket}
520 end
521
522 0 def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket}
523
524 1 defp save_timer(socket, :edit_timer, timer_params) do
525 1 case TimeTracking.update_timer(socket.assigns.timer, timer_params) do
526 {:ok, timer} ->
527 1 if timer.start_stamp != "" && timer.end_stamp != "" && not is_nil(timer.end_stamp) do
528 1 notify_parent({:updated_closed_timer, timer})
529 else
530 0 notify_parent({:updated_open_timer, timer})
531 end
532
533 {:noreply,
534 socket
535 1 |> push_patch(to: socket.assigns.patch)}
536
537 0 {:error, %Ecto.Changeset{} = changeset} ->
538 {:noreply, assign_form(socket, changeset)}
539 end
540 end
541
542 defp assign_form(socket, %Ecto.Changeset{} = changeset) do
543 2 assign(socket, :form, to_form(changeset))
544 end
545
546 @spec assign_project(Phoenix.LiveView.Socket.t()) ::
547 Phoenix.LiveView.Socket.t()
548 defp assign_project(socket) do
549 2 projects = Project.populate_projects_list()
550
551 2 assign(socket, projects: projects)
552 end
553
554 @spec assign_business_partner(Phoenix.LiveView.Socket.t()) ::
555 Phoenix.LiveView.Socket.t()
556 defp assign_business_partner(socket) do
557 2 business_partners = BusinessPartner.populate_customers_list()
558
559 2 assign(socket, business_partners: business_partners)
560 end
561
562 @spec assign_activity_type(Phoenix.LiveView.Socket.t()) ::
563 Phoenix.LiveView.Socket.t()
564 defp assign_activity_type(socket) do
565 2 activity_types =
566 2 case socket.assigns.billable_activity? do
567 true ->
568 0 ActivityType.populate_activity_types_list()
569
570 2 _ ->
571 [{"", ""}]
572 end
573
574 2 assign(socket, activity_types: activity_types)
575 end
576
577 1 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
578
579 defp enable_tag_selector() do
580 JS.remove_class("hidden", to: "#timer_ls_tag_search_text_input")
581 |> JS.remove_class("hidden", to: "#tag-selector__colour-select")
582 |> JS.add_class("hidden", to: "#tag-selector__add-button")
583 |> JS.add_class("gap-2", to: "#tag-selector")
584 |> JS.add_class("flex-auto", to: "#tag-selector__live-select")
585 2 |> JS.focus(to: "#timer_ls_tag_search_text_input")
586 end
587 end

lib/klepsidra_web/live/timer_live/index.ex

39.3
33
55
20
Line Hits Source
0 defmodule KlepsidraWeb.TimerLive.Index do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.TimeTracking
6 import LiveToast
7 alias Klepsidra.TimeTracking.Timer
8 alias Klepsidra.TimeTracking.TimeUnits, as: Units
9 alias KlepsidraWeb.Live.NoteLive.NoteFormComponent
10
11 @impl true
12 8 def mount(_params, _session, socket) do
13 8 socket =
14 socket
15 |> assign(display_help: false)
16 |> stream(:timers, TimeTracking.list_timers_with_customers())
17
18 {:ok, socket}
19 end
20
21 @impl true
22 11 def handle_params(params, _url, socket) do
23 11 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
24 end
25
26 defp apply_action(socket, :new_timer, _params) do
27 socket
28 |> assign(:page_title, "Manual Timer")
29 1 |> assign(:timer, %Timer{})
30 end
31
32 defp apply_action(socket, :edit_timer, %{"id" => id}) do
33 socket
34 |> assign(:page_title, "Edit Timer")
35 1 |> assign(:timer, TimeTracking.get_timer!(id))
36 end
37
38 defp apply_action(socket, :start_timer, _params) do
39 0 billing_duration_unit = Units.get_default_billing_increment()
40
41 socket
42 |> assign(:page_title, "Starting Timer")
43 |> assign(
44 duration_unit: "minute",
45 billing_duration_unit: billing_duration_unit
46 )
47 0 |> assign(:timer, %Timer{})
48 end
49
50 defp apply_action(socket, :stop_timer, %{"id" => id}) do
51 0 start_timestamp = TimeTracking.get_timer!(id).start_stamp
52 0 clocked_out = Timer.clock_out(start_timestamp, :minute)
53 0 billing_duration_unit = Units.get_default_billing_increment()
54
55 0 billing_duration =
56 Timer.calculate_timer_duration(
57 start_timestamp,
58 0 clocked_out.end_timestamp,
59 String.to_atom(billing_duration_unit)
60 )
61
62 socket
63 |> assign(:page_title, "Clock out")
64 |> assign(
65 clocked_out: clocked_out,
66 duration_unit: "minute",
67 billing_duration: billing_duration,
68 billing_duration_unit: billing_duration_unit
69 )
70 0 |> assign(:timer, TimeTracking.get_timer!(id))
71 end
72
73 defp apply_action(socket, :index, _params) do
74 socket
75 |> assign(:page_title, "Activity Timers")
76 9 |> assign(:timer, nil)
77 end
78
79 defp apply_action(socket, :new_note, %{"id" => id} = _params) do
80 socket
81 |> assign(:page_title, "New note")
82 0 |> assign(:timer_id, id)
83 end
84
85 @impl true
86 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_open_timer, timer}}, socket) do
87 {:noreply, handle_open_timer(socket, timer)}
88 end
89
90 @impl true
91 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_closed_timer, timer}}, socket) do
92 {:noreply, handle_closed_timer(socket, timer)}
93 end
94
95 @impl true
96 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_open_timer, timer}}, socket) do
97 {:noreply, handle_updated_timer(socket, timer)}
98 end
99
100 @impl true
101 1 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_closed_timer, timer}}, socket) do
102 {:noreply, handle_updated_timer(socket, timer)}
103 end
104
105 @impl true
106 0 def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_started, timer}}, socket) do
107 {:noreply, handle_started_timer(socket, timer)}
108 end
109
110 @impl true
111 0 def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_stopped, timer}}, socket) do
112 {:noreply, handle_closed_timer(socket, timer)}
113 end
114
115 @impl true
116 0 def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:saved_note, note}}, socket) do
117 {:noreply, handle_saved_note(socket, note)}
118 end
119
120 @impl true
121 1 def handle_event("delete", %{"id" => id}, socket) do
122 1 timer = TimeTracking.get_timer!(id)
123 1 {:ok, _} = TimeTracking.delete_timer(timer)
124
125 {:noreply, handle_deleted_timer(socket, timer, :timers)}
126 end
127
128 @impl true
129 # def handle_event("keyboard_event", %{"key" => "s"} = _params, socket) do
130 # {:noreply,
131 # assign(socket,
132 # live_action: :start,
133 # page_title: "Starting Timer",
134 # start_timestamp:
135 # Timer.get_current_timestamp()
136 # |> Timer.convert_naivedatetime_to_html!(),
137 # timer: %Timer{}
138 # )}
139 # end
140
141 # def handle_event("keyboard_event", %{"key" => "?"} = _params, socket) do
142 # {:noreply,
143 # assign(socket,
144 # display_help: true
145 # )}
146 # end
147
148 0 def handle_event("keyboard_event", _params, socket) do
149 {:noreply, socket}
150 end
151
152 defp handle_started_timer(socket, timer) do
153 socket
154 |> stream_insert(:timers, timer, at: 0)
155 0 |> put_toast(:info, "Timer started")
156 end
157
158 defp handle_open_timer(socket, timer) do
159 socket
160 |> stream_insert(:timers, timer)
161 0 |> put_toast(:info, "Timer created successfully")
162 end
163
164 defp handle_closed_timer(socket, timer) do
165 socket
166 |> stream_insert(:timers, timer)
167 0 |> put_toast(:info, "Timer stopped")
168 end
169
170 defp handle_updated_timer(socket, timer) do
171 socket
172 |> stream_insert(:timers, timer)
173 1 |> put_toast(:info, "Timer updated successfully")
174 end
175
176 defp handle_deleted_timer(socket, timer, source_stream) do
177 socket
178 |> stream_delete(source_stream, timer)
179 1 |> put_toast(:info, "Timer deleted successfully")
180 end
181
182 defp handle_saved_note(socket, _note) do
183 socket
184 0 |> put_toast(:info, "Note created successfully")
185 end
186 end

lib/klepsidra_web/live/timer_live/show.ex

26.7
86
50
63
Line Hits Source
0 defmodule KlepsidraWeb.TimerLive.Show do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 import LiveToast
6
7 alias Klepsidra.TimeTracking
8 alias Klepsidra.TimeTracking.Timer
9 alias Klepsidra.TimeTracking.TimeUnits, as: Units
10 alias KlepsidraWeb.Live.NoteLive.NoteFormComponent
11 alias Klepsidra.Categorisation
12 alias Klepsidra.Categorisation.Tag
13 alias KlepsidraWeb.TagLive.TagUtilities
14 alias LiveSelect.Component
15 alias Klepsidra.DynamicCSS
16
17 defmodule TagSearch do
18 @moduledoc """
19 The `TagSearch` module defines an embedded `tag_search` schema
20 containing the tags for this timer.
21 """
22 use Ecto.Schema
23
24 import Ecto.Changeset
25
26 @type t :: %__MODULE__{
27 tag_search: Tag.t()
28 }
29 6 embedded_schema do
30 embeds_many(:tag_search, Tag, on_replace: :delete)
31 field(:bg_colour, :string)
32 end
33
34 @doc false
35 def changeset(schema \\ %__MODULE__{}, params) do
36 cast(schema, params, [])
37 2 |> cast_embed(:tag_search)
38 end
39 end
40
41 @tag_search_live_component_id "tag_form_tag_search_live_select_component"
42
43 @impl true
44 2 def mount(params, _session, socket) do
45 2 timer_id = Map.get(params, "id")
46 2 timer = Klepsidra.TimeTracking.get_timer!(timer_id) |> Klepsidra.Repo.preload(:tags)
47 2 return_to = Map.get(params, "return_to", "/timers")
48
49 2 notes = TimeTracking.get_note_by_timer_id!(timer_id)
50
51 2 note_metadata = title_notes_section(length(notes))
52
53 2 socket =
54 socket
55 |> TagUtilities.generate_tag_options(
56 [],
57 2 Enum.map(timer.tags, fn tag -> tag.id end),
58 @tag_search_live_component_id
59 )
60 |> Phx.Live.Head.push(
61 "style[id*=dynamic-style-block]",
62 :dynamic,
63 "style_declarations",
64 2 DynamicCSS.generate_tag_styles(timer.tags)
65 )
66
67 2 socket =
68 socket
69 |> stream(:notes, notes)
70 |> assign(
71 live_select_form: to_form(TagSearch.changeset(%{}), as: "tag_form"),
72 new_tag_colour: {"#94a3b8", "#fff"},
73 2 note_count: note_metadata.note_count,
74 2 notes_title: note_metadata.section_title,
75 timer_id: timer_id,
76 return_to: return_to
77 )
78
79 {:ok, socket}
80 end
81
82 @impl true
83 2 def handle_params(params, _url, socket) do
84 2 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
85 end
86
87 defp apply_action(socket, :show, %{"id" => id}) do
88 socket
89 2 |> assign(:page_title, page_title(socket.assigns.live_action))
90 |> assign(:timer, TimeTracking.get_timer!(id))
91 2 |> assign(:note, %Klepsidra.TimeTracking.Note{})
92 end
93
94 defp apply_action(socket, :edit_timer, %{"id" => id}) do
95 socket
96 |> assign(:page_title, "Edit Timer")
97 0 |> assign(:timer, TimeTracking.get_timer!(id))
98 end
99
100 defp apply_action(socket, :stop_timer, %{"id" => id}) do
101 0 start_timestamp = TimeTracking.get_timer!(id).start_stamp
102 0 clocked_out = Timer.clock_out(start_timestamp, :minute)
103 0 billing_duration_unit = Units.get_default_billing_increment()
104
105 0 billing_duration =
106 Timer.calculate_timer_duration(
107 start_timestamp,
108 0 clocked_out.end_timestamp,
109 String.to_existing_atom(billing_duration_unit)
110 )
111
112 socket
113 |> assign(:page_title, "Clock out")
114 |> assign(
115 clocked_out: clocked_out,
116 duration_unit: "minute",
117 billing_duration: billing_duration,
118 billing_duration_unit: billing_duration_unit
119 )
120 0 |> assign(:timer, TimeTracking.get_timer!(id))
121 end
122
123 defp apply_action(socket, :new_note, %{"id" => id} = _params) do
124 socket
125 |> assign(:page_title, "New note")
126 0 |> assign(:timer_id, id)
127 end
128
129 0 defp apply_action(socket, nil, _params), do: socket
130
131 defp apply_action(socket, :edit_note, %{"id" => _id, "note_id" => note_id}) do
132 socket
133 0 |> assign(
134 note: TimeTracking.get_note!(note_id),
135 0 page_title: page_title(socket.assigns.live_action)
136 )
137 end
138
139 2 defp page_title(:show), do: "Show Timer"
140 0 defp page_title(:edit_timer), do: "Edit Timer"
141 0 defp page_title(:new_note), do: "New note"
142 0 defp page_title(:edit_note), do: "Edit note"
143
144 @impl true
145 0 def handle_event(
146 "live_select_change",
147 %{
148 "field" => "tag_form_tag_search",
149 "id" => @tag_search_live_component_id,
150 "text" => tag_search_phrase
151 },
152 socket
153 ) do
154 0 tag_search_results =
155 Categorisation.search_tags_by_name_content(tag_search_phrase)
156 |> TagUtilities.tag_options_for_live_select()
157
158 0 send_update(Component,
159 id: @tag_search_live_component_id,
160 options: tag_search_results
161 )
162
163 0 socket =
164 socket
165 |> assign(
166 tag_search_phrase: tag_search_phrase,
167 possible_free_tag_entered: true
168 )
169
170 {:noreply, socket}
171 end
172
173 0 def handle_event(
174 "change",
175 %{
176 "_target" => ["tag_form", "tag_search_empty_selection"],
177 "tag_form" => %{
178 "tag_search_empty_selection" => "",
179 "tag_search_text_input" => _tag_search_phrase
180 }
181 },
182 socket
183 ) do
184 0 Tag.handle_tag_list_changes(
185 0 socket.assigns.selected_tag_queue,
186 [],
187 0 socket.assigns.timer.id,
188 &Categorisation.add_timer_tag(&1, &2),
189 &Categorisation.delete_timer_tag(&1, &2)
190 )
191
192 0 socket =
193 socket
194 |> assign(
195 tag_search_phrase: nil,
196 possible_free_tag_entered: false
197 )
198
199 {:noreply, socket}
200 end
201
202 0 def handle_event(
203 "change",
204 %{
205 "_target" => ["tag_form", "tag_search"],
206 "tag_form" => %{
207 "tag_search" => selected_tags,
208 "tag_search_text_input" => _tag_search_phrase
209 }
210 },
211 socket
212 ) do
213 0 Tag.handle_tag_list_changes(
214 0 socket.assigns.selected_tag_queue,
215 selected_tags,
216 0 socket.assigns.timer.id,
217 &Categorisation.add_timer_tag(&1, &2),
218 &Categorisation.delete_timer_tag(&1, &2)
219 )
220
221 0 socket =
222 TagUtilities.generate_tag_options(
223 socket,
224 0 socket.assigns.selected_tag_queue,
225 selected_tags,
226 @tag_search_live_component_id
227 )
228 |> Phx.Live.Head.push(
229 "style[id*=dynamic-style-block]",
230 :dynamic,
231 "style_declarations",
232 DynamicCSS.generate_tag_styles(selected_tags)
233 )
234
235 0 socket =
236 socket
237 |> assign(
238 tag_search_phrase: nil,
239 possible_free_tag_entered: false
240 )
241
242 {:noreply, socket}
243 end
244
245 0 def handle_event(
246 "change",
247 %{
248 "_target" => ["tag_form", "bg_colour"],
249 "tag_form" => %{
250 "bg_colour" => bg_colour,
251 "tag_search_text_input" => _tag_search_phrase
252 }
253 },
254 socket
255 ) do
256 0 fg_colour =
257 case ColorContrast.calc_contrast(bg_colour) do
258 0 {:ok, fg_colour} -> fg_colour
259 0 {:error, _} -> "#fff"
260 end
261
262 0 socket =
263 socket
264 |> assign(new_tag_colour: {bg_colour, fg_colour})
265
266 {:noreply, socket}
267 end
268
269 0 def handle_event(
270 "ls_tag_search_blur",
271 %{"id" => @tag_search_live_component_id},
272 socket
273 ) do
274 0 socket =
275 socket
276 |> assign(
277 tag_search_phrase: nil,
278 possible_free_tag_entered: false
279 )
280
281 {:noreply, socket}
282 end
283
284 0 def handle_event(
285 "key_up",
286 %{"key" => "Enter"},
287 %{assigns: %{tag_search_phrase: tag_search_phrase, possible_free_tag_entered: true}} =
288 socket
289 ) do
290 0 socket =
291 TagUtilities.handle_free_tagging(
292 socket,
293 tag_search_phrase,
294 String.length(tag_search_phrase),
295 @tag_search_live_component_id,
296 0 socket.assigns.new_tag_colour
297 )
298
299 {:noreply, socket}
300 end
301
302 0 def handle_event("key_up", %{"key" => _}, socket), do: {:noreply, socket}
303
304 0 def handle_event("delete_note", %{"id" => id}, socket) do
305 {:noreply, handle_deleted_note(socket, TimeTracking.get_note!(id))}
306 end
307
308 @impl true
309 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved, _timer}}, socket) do
310 {:noreply, socket}
311 end
312
313 @impl true
314 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:saved_closed_timer, timer}}, socket) do
315 {:noreply, handle_closed_timer(socket, timer)}
316 end
317
318 @impl true
319 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_open_timer, _timer}}, socket) do
320 {:noreply, socket}
321 end
322
323 @impl true
324 0 def handle_info({KlepsidraWeb.TimerLive.FormComponent, {:updated_closed_timer, _timer}}, socket) do
325 {:noreply, socket}
326 end
327
328 @impl true
329 0 def handle_info({KlepsidraWeb.TimerLive.AutomatedTimer, {:timer_stopped, timer}}, socket) do
330 {:noreply, handle_closed_timer(socket, timer)}
331 end
332
333 @impl true
334 0 def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:updated_note, note}}, socket) do
335 {:noreply, handle_updated_note(socket, note)}
336 end
337
338 @impl true
339 0 def handle_info({KlepsidraWeb.Live.NoteLive.NoteFormComponent, {:saved_note, note}}, socket) do
340 {:noreply, handle_saved_note(socket, note)}
341 end
342
343 defp handle_closed_timer(socket, _timer) do
344 # closed_timer_duration = {timer.duration, timer.duration_time_unit}
345
346 socket
347 0 |> put_toast(:info, "Timer stopped")
348 end
349
350 defp handle_saved_note(socket, note) do
351 0 note_metadata = title_notes_section(socket.assigns.note_count + 1)
352
353 socket
354 0 |> assign(:note_count, note_metadata.note_count)
355 0 |> assign(:notes_title, note_metadata.section_title)
356 |> stream_insert(:notes, note, at: 0)
357 0 |> put_toast(:info, "Note created successfully")
358 end
359
360 defp handle_updated_note(socket, note) do
361 0 note_metadata = title_notes_section(socket.assigns.note_count + 1)
362
363 socket
364 0 |> assign(:note_count, note_metadata.note_count)
365 0 |> assign(:notes_title, note_metadata.section_title)
366 |> stream_insert(:notes, note)
367 0 |> put_toast(:info, "Note updated successfully")
368 end
369
370 defp handle_deleted_note(socket, note) do
371 0 {:ok, _} = TimeTracking.delete_note(note)
372
373 0 note_metadata = title_notes_section(socket.assigns.note_count - 1)
374
375 socket
376 0 |> assign(:note_count, note_metadata.note_count)
377 0 |> assign(:notes_title, note_metadata.section_title)
378 |> stream_delete(:notes, note)
379 0 |> put_toast(:info, "Note deleted successfully")
380 end
381
382 defp title_notes_section(note_count) when is_integer(note_count) do
383 2 title_note_count = if note_count > 0, do: note_count, else: ""
384 2 note_pluralisation = if note_count == 1, do: "Note", else: "Notes"
385
386 2 %{
387 note_count: note_count,
388 title_pluralisation: note_pluralisation,
389 section_title: [title_note_count, note_pluralisation] |> Enum.join(" ")
390 }
391 end
392
393 defp enable_tag_selector() do
394 JS.remove_class("hidden", to: "#tag_form_tag_search_text_input")
395 |> JS.remove_class("hidden", to: "#tag-selector__colour-select--show")
396 |> JS.add_class("hidden", to: "#tag-selector__add-button--show")
397 |> JS.add_class("gap-2", to: "#tag-selector--show")
398 |> JS.add_class("flex-auto", to: "#tag-selector__live-select--show")
399 2 |> JS.focus(to: "#tag_form_tag_search_text_input")
400 end
401 end

lib/klepsidra_web/live/user_live/form_component.ex

85.7
28
101
4
Line Hits Source
0 defmodule KlepsidraWeb.UserLive.FormComponent do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_component
4
5 alias Klepsidra.Accounts
6
7 @impl true
8 def render(assigns) do
9 9 ~H"""
10 <div>
11 3 <.header>
12 3 <%= @title %>
13 3 <:subtitle>Use this form to manage user records in your database.</:subtitle>
14 </.header>
15
16 7 <.simple_form
17 7 for={@form}
18 id="user-form"
19 7 phx-target={@myself}
20 phx-change="validate"
21 phx-submit="save"
22 >
23 7 <.input field={@form[:user_name]} type="text" label="User name" />
24 7 <.input field={@form[:login_email]} type="text" label="Login email" />
25 7 <.input field={@form[:password_hash]} type="text" label="Password hash" />
26 7 <:actions>
27 7 <.button phx-disable-with="Saving...">Save User</.button>
28 </:actions>
29 </.simple_form>
30 </div>
31 """
32 end
33
34 @impl true
35 3 def update(%{user: user} = assigns, socket) do
36 {:ok,
37 socket
38 |> assign(assigns)
39 |> assign_new(:form, fn ->
40 3 to_form(Accounts.change_user(user))
41 end)}
42 end
43
44 @impl true
45 3 def handle_event("validate", %{"user" => user_params}, socket) do
46 3 changeset = Accounts.change_user(socket.assigns.user, user_params)
47 {:noreply, assign(socket, form: to_form(changeset, action: :validate))}
48 end
49
50 def handle_event("save", %{"user" => user_params}, socket) do
51 3 save_user(socket, socket.assigns.action, user_params)
52 end
53
54 2 defp save_user(socket, :edit, user_params) do
55 2 case Accounts.update_user(socket.assigns.user, user_params) do
56 {:ok, user} ->
57 2 notify_parent({:saved, user})
58
59 {:noreply,
60 socket
61 |> put_flash(:info, "User updated successfully")
62 2 |> push_patch(to: socket.assigns.patch)}
63
64 0 {:error, %Ecto.Changeset{} = changeset} ->
65 {:noreply, assign(socket, form: to_form(changeset))}
66 end
67 end
68
69 0 defp save_user(socket, :new, user_params) do
70 1 case Accounts.create_user(user_params) do
71 {:ok, user} ->
72 0 notify_parent({:saved, user})
73
74 {:noreply,
75 socket
76 |> put_flash(:info, "User created successfully")
77 0 |> push_patch(to: socket.assigns.patch)}
78
79 1 {:error, %Ecto.Changeset{} = changeset} ->
80 {:noreply, assign(socket, form: to_form(changeset))}
81 end
82 end
83
84 2 defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
85 end

lib/klepsidra_web/live/user_live/index.ex

100.0
10
45
0
Line Hits Source
0 defmodule KlepsidraWeb.UserLive.Index do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.Accounts
6 alias Klepsidra.Accounts.User
7
8 @impl true
9 8 def mount(_params, _session, socket) do
10 {:ok, stream(socket, :users, Accounts.list_users())}
11 end
12
13 @impl true
14 11 def handle_params(params, _url, socket) do
15 11 {:noreply, apply_action(socket, socket.assigns.live_action, params)}
16 end
17
18 defp apply_action(socket, :edit, %{"id" => id}) do
19 socket
20 |> assign(:page_title, "Edit User")
21 1 |> assign(:user, Accounts.get_user!(id))
22 end
23
24 defp apply_action(socket, :new, _params) do
25 socket
26 |> assign(:page_title, "New User")
27 1 |> assign(:user, %User{})
28 end
29
30 defp apply_action(socket, :index, _params) do
31 socket
32 |> assign(:page_title, "Listing Users")
33 9 |> assign(:user, nil)
34 end
35
36 @impl true
37 1 def handle_info({KlepsidraWeb.UserLive.FormComponent, {:saved, user}}, socket) do
38 {:noreply, stream_insert(socket, :users, user)}
39 end
40
41 @impl true
42 1 def handle_event("delete", %{"id" => id}, socket) do
43 1 user = Accounts.get_user!(id)
44 1 {:ok, _} = Accounts.delete_user(user)
45
46 {:noreply, stream_delete(socket, :users, user)}
47 end
48 end

lib/klepsidra_web/live/user_live/show.ex

100.0
5
22
0
Line Hits Source
0 defmodule KlepsidraWeb.UserLive.Show do
1 @moduledoc false
2
3 use KlepsidraWeb, :live_view
4
5 alias Klepsidra.Accounts
6
7 @impl true
8 4 def mount(_params, _session, socket) do
9 {:ok, socket}
10 end
11
12 @impl true
13 6 def handle_params(%{"id" => id}, _, socket) do
14 {:noreply,
15 socket
16 6 |> assign(:page_title, page_title(socket.assigns.live_action))
17 |> assign(:user, Accounts.get_user!(id))}
18 end
19
20 5 defp page_title(:show), do: "Show User"
21 1 defp page_title(:edit), do: "Edit User"
22 end

lib/klepsidra_web/router.ex

55.7
61
147
27
Line Hits Source
0 defmodule KlepsidraWeb.Router do
1 use KlepsidraWeb, :router
2
3 @moduledoc false
4
5 39 pipeline :browser do
6 plug :accepts, ["html"]
7 plug :fetch_session
8 plug :fetch_live_flash
9
10 plug :put_root_layout,
11 html: {KlepsidraWeb.Layouts, :root}
12
13 plug :protect_from_forgery
14
15 plug :put_content_security_policy,
16 default_src: "'none'",
17 script_src: "'self' 'nonce'",
18 style_src: "'self' 'nonce'",
19 connect_src: "'self'",
20 img_src: "'self' data:",
21 font_src: "'self'",
22 frame_src: "'self' 'nonce'"
23
24 plug :put_secure_browser_headers
25 end
26
27 0 pipeline :api do
28 plug :accepts, ["json"]
29 end
30
31 scope "/", KlepsidraWeb do
32 pipe_through :browser
33
34 0 live "/", StartPageLive
35 0 live "/start_timer", StartPageLive, :start_timer
36 0 live "/stop_timer/:id", StartPageLive, :stop_timer
37 0 live "/new_timer", StartPageLive, :new_timer
38 0 live "/timer_notes/:id/notes/new", StartPageLive, :new_note
39 0 live "/edit_timer/:id", StartPageLive, :edit_timer
40
41 9 live "/users", UserLive.Index, :index
42 1 live "/users/new", UserLive.Index, :new
43 1 live "/users/:id/edit", UserLive.Index, :edit
44
45 5 live "/users/:id", UserLive.Show, :show
46 1 live "/users/:id/show/edit", UserLive.Show, :edit
47
48 9 live "/tags", TagLive.Index, :index
49 1 live "/tags/new", TagLive.Index, :new
50 1 live "/tags/:id/edit", TagLive.Index, :edit
51
52 5 live "/tags/:id", TagLive.Show, :show
53 1 live "/tags/:id/show/edit", TagLive.Show, :edit
54
55 9 live "/timers", TimerLive.Index, :index
56 1 live "/timers/new", TimerLive.Index, :new_timer
57 0 live "/timers/start", TimerLive.Index, :start_timer
58 1 live "/timers/:id/edit", TimerLive.Index, :edit_timer
59 0 live "/timers/:id/stop", TimerLive.Index, :stop_timer
60 0 live "/timers/:id/notes/new", TimerLive.Index, :new_note
61
62 2 live "/timers/:id", TimerLive.Show, :show
63 0 live "/timers/:id/stop-timer", TimerLive.Show, :stop_timer
64 0 live "/timers/:id/new-note", TimerLive.Show, :new_note
65 0 live "/timers/:id/notes/:note_id/edit", TimerLive.Show, :edit_note
66 0 live "/timers/:id/show/edit", TimerLive.Show, :edit_timer
67
68 9 live "/activity_types", ActivityTypeLive.Index, :index
69 1 live "/activity_types/new", ActivityTypeLive.Index, :new
70 1 live "/activity_types/:id/edit", ActivityTypeLive.Index, :edit
71
72 5 live "/activity_types/:id", ActivityTypeLive.Show, :show
73 1 live "/activity_types/:id/show/edit", ActivityTypeLive.Show, :edit
74
75 0 live "/notes", NoteLive.Index, :index
76 0 live "/notes/new", NoteLive.Index, :new
77 0 live "/notes/:id/edit", NoteLive.Index, :edit
78
79 0 live "/notes/:id", NoteLive.Show, :show
80 0 live "/notes/:id/show/edit", NoteLive.Show, :edit
81
82 9 live "/projects", ProjectLive.Index, :index
83 1 live "/projects/new", ProjectLive.Index, :new
84 1 live "/projects/:id/edit", ProjectLive.Index, :edit
85
86 5 live "/projects/:id", ProjectLive.Show, :show
87 1 live "/projects/:id/show/edit", ProjectLive.Show, :edit
88
89 9 live "/customers", BusinessPartnerLive.Index, :index
90 1 live "/customers/new", BusinessPartnerLive.Index, :new
91 1 live "/customers/:id/edit", BusinessPartnerLive.Index, :edit
92
93 5 live "/customers/:id", BusinessPartnerLive.Show, :show
94 1 live "/customers/:id/show/edit", BusinessPartnerLive.Show, :edit
95
96 4 live "/journal_entry_types", JournalEntryTypesLive.Index, :index
97 1 live "/journal_entry_types/new", JournalEntryTypesLive.Index, :new
98 0 live "/journal_entry_types/:id/edit", JournalEntryTypesLive.Index, :edit
99
100 4 live "/journal_entry_types/:id", JournalEntryTypesLive.Show, :show
101 1 live "/journal_entry_types/:id/show/edit", JournalEntryTypesLive.Show, :edit
102
103 0 live "/journal_entries", JournalEntryLive.Index, :index
104 0 live "/journal_entries/new", JournalEntryLive.Index, :new
105 0 live "/journal_entries/:id/edit", JournalEntryLive.Index, :edit
106
107 0 live "/journal_entries/:id", JournalEntryLive.Show, :show
108 0 live "/journal_entries/:id/show/edit", JournalEntryLive.Show, :edit
109
110 0 live "/reporting/activities_timed", TimerLive.ActivityTimeReporting, :index
111
112 0 live "/reporting/activities_timed/timers/:id/edit",
113 TimerLive.ActivityTimeReporting,
114 :edit_timer
115 end
116
117 # Other scopes may use custom stacks.
118 # scope "/api", KlepsidraWeb do
119 # pipe_through :api
120 # end
121
122 # Enable LiveDashboard and Swoosh mailbox preview in development
123 if Application.compile_env(:klepsidra, :dev_routes) do
124 # If you want to use the LiveDashboard in production, you should put
125 # it behind authentication and allow only admins to access it.
126 # If your application does not have an admins-only section yet,
127 # you can use Plug.BasicAuth to set up some basic authentication
128 # as long as you are also using SSL (which you should anyway).
129 import Phoenix.LiveDashboard.Router
130
131 scope "/dev" do
132 pipe_through :browser
133
134 live_dashboard "/dashboard", metrics: KlepsidraWeb.Telemetry
135 forward "/mailbox", Plug.Swoosh.MailboxPreview
136 end
137 end
138 end

lib/klepsidra_web/telemetry.ex

80.0
5
4
1
Line Hits Source
0 defmodule KlepsidraWeb.Telemetry do
1 use Supervisor
2 import Telemetry.Metrics
3
4 @moduledoc false
5
6 def start_link(arg) do
7 1 Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
8 end
9
10 @impl true
11 def init(_arg) do
12 1 children = [
13 # Telemetry poller will execute the given period measurements
14 # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
15 {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
16 # Add reporters as children of your supervision tree.
17 # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
18 ]
19
20 1 Supervisor.init(children, strategy: :one_for_one)
21 end
22
23 0 def metrics do
24 [
25 # Phoenix Metrics
26 summary("phoenix.endpoint.start.system_time",
27 unit: {:native, :millisecond}
28 ),
29 summary("phoenix.endpoint.stop.duration",
30 unit: {:native, :millisecond}
31 ),
32 summary("phoenix.router_dispatch.start.system_time",
33 tags: [:route],
34 unit: {:native, :millisecond}
35 ),
36 summary("phoenix.router_dispatch.exception.duration",
37 tags: [:route],
38 unit: {:native, :millisecond}
39 ),
40 summary("phoenix.router_dispatch.stop.duration",
41 tags: [:route],
42 unit: {:native, :millisecond}
43 ),
44 summary("phoenix.socket_connected.duration",
45 unit: {:native, :millisecond}
46 ),
47 summary("phoenix.channel_join.duration",
48 unit: {:native, :millisecond}
49 ),
50 summary("phoenix.channel_handled_in.duration",
51 tags: [:event],
52 unit: {:native, :millisecond}
53 ),
54
55 # Database Metrics
56 summary("klepsidra.repo.query.total_time",
57 unit: {:native, :millisecond},
58 description: "The sum of the other measurements"
59 ),
60 summary("klepsidra.repo.query.decode_time",
61 unit: {:native, :millisecond},
62 description: "The time spent decoding the data received from the database"
63 ),
64 summary("klepsidra.repo.query.query_time",
65 unit: {:native, :millisecond},
66 description: "The time spent executing the query"
67 ),
68 summary("klepsidra.repo.query.queue_time",
69 unit: {:native, :millisecond},
70 description: "The time spent waiting for a database connection"
71 ),
72 summary("klepsidra.repo.query.idle_time",
73 unit: {:native, :millisecond},
74 description:
75 "The time the connection spent waiting before being checked out for the query"
76 ),
77
78 # VM Metrics
79 summary("vm.memory.total", unit: {:byte, :kilobyte}),
80 summary("vm.total_run_queue_lengths.total"),
81 summary("vm.total_run_queue_lengths.cpu"),
82 summary("vm.total_run_queue_lengths.io")
83 ]
84 end
85
86 1 defp periodic_measurements do
87 [
88 # A module, function and arguments to be invoked periodically.
89 # This function must call :telemetry.execute/3 and a metric must be added above.
90 # {KlepsidraWeb, :count_users, []}
91 ]
92 end
93 end

test/support/conn_case.ex

100.0
2
53
0
Line Hits Source
0 defmodule KlepsidraWeb.ConnCase do
1 @moduledoc """
2 This module defines the test case to be used by
3 tests that require setting up a connection.
4
5 Such tests rely on `Phoenix.ConnTest` and also
6 import other functionality to make it easier
7 to build common data structures and query the data layer.
8
9 Finally, if the test case interacts with the database,
10 we enable the SQL sandbox, so changes done to the database
11 are reverted at the end of every test. If you are using
12 PostgreSQL, you can even run database tests asynchronously
13 by setting `use KlepsidraWeb.ConnCase, async: true`, although
14 this option is not recommended for other databases.
15 """
16
17 use ExUnit.CaseTemplate
18
19 10 using do
20 quote do
21 # The default endpoint for testing
22 @endpoint KlepsidraWeb.Endpoint
23
24 use KlepsidraWeb, :verified_routes
25
26 # Import conveniences for testing with connections
27 import Plug.Conn
28 import Phoenix.ConnTest
29 import KlepsidraWeb.ConnCase
30 end
31 end
32
33 setup tags do
34 43 Klepsidra.DataCase.setup_sandbox(tags)
35 {:ok, conn: Phoenix.ConnTest.build_conn()}
36 end
37 end

test/support/data_case.ex

57.1
7
472
3
Line Hits Source
0 defmodule Klepsidra.DataCase do
1 @moduledoc """
2 This module defines the setup for tests requiring
3 access to the application's data layer.
4
5 You may define functions here to be used as helpers in
6 your tests.
7
8 Finally, if the test case interacts with the database,
9 we enable the SQL sandbox, so changes done to the database
10 are reverted at the end of every test. If you are using
11 PostgreSQL, you can even run database tests asynchronously
12 by setting `use Klepsidra.DataCase, async: true`, although
13 this option is not recommended for other databases.
14 """
15
16 use ExUnit.CaseTemplate
17
18 8 using do
19 quote do
20 alias Klepsidra.Repo
21
22 import Ecto
23 import Ecto.Changeset
24 import Ecto.Query
25 import Klepsidra.DataCase
26 end
27 end
28
29 setup tags do
30 126 Klepsidra.DataCase.setup_sandbox(tags)
31 :ok
32 end
33
34 @doc """
35 Sets up the sandbox based on the test tags.
36 """
37 def setup_sandbox(tags) do
38 169 pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Klepsidra.Repo, shared: not tags[:async])
39 169 on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
40 end
41
42 @doc """
43 A helper that transforms changeset errors into a map of messages.
44
45 assert {:error, changeset} = Accounts.create_user(%{password: "short"})
46 assert "password is too short" in errors_on(changeset).password
47 assert %{password: ["password is too short"]} = errors_on(changeset)
48
49 """
50 def errors_on(changeset) do
51 0 Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
52 0 Regex.replace(~r"%{(\w+)}", message, fn _, key ->
53 0 opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
54 end)
55 end)
56 end
57 end

test/support/fixtures/accounts_fixtures.ex

100.0
2
24
0
Line Hits Source
0 defmodule Klepsidra.AccountsFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.Accounts` context.
4 """
5
6 @doc """
7 Generate a user.
8 """
9 def user_fixture(attrs \\ %{}) do
10 12 {:ok, user} =
11 attrs
12 |> Enum.into(%{
13 login_email: "some login_email",
14 password_hash: "some password_hash",
15 user_name: "some user_name"
16 })
17 |> Klepsidra.Accounts.create_user()
18
19 12 user
20 end
21 end

test/support/fixtures/business_partners_fixtures.ex

50.0
4
24
2
Line Hits Source
0 defmodule Klepsidra.BusinessPartnersFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.BusinessPartners` context.
4 """
5
6 @doc """
7 Generate a business_partner.
8 """
9 def business_partner_fixture(attrs \\ %{}) do
10 12 {:ok, business_partner} =
11 attrs
12 |> Enum.into(%{
13 active: true,
14 customer: true,
15 description: "some description",
16 name: "some name",
17 supplier: true
18 })
19 |> Klepsidra.BusinessPartners.create_business_partner()
20
21 12 business_partner
22 end
23
24 @doc """
25 Generate a note.
26 """
27 def note_fixture(attrs \\ %{}) do
28 0 {:ok, note} =
29 attrs
30 |> Enum.into(%{
31 business_partner_id: 42,
32 note: "some note",
33 user_id: 42
34 })
35 |> Klepsidra.BusinessPartners.create_note()
36
37 0 note
38 end
39 end

test/support/fixtures/categorisation_fixtures.ex

50.0
4
24
2
Line Hits Source
0 defmodule Klepsidra.CategorisationFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.Categorisation` context.
4 """
5
6 @doc """
7 Generate a tag.
8 """
9 def tag_fixture(attrs \\ %{}) do
10 12 {:ok, tag} =
11 attrs
12 |> Enum.into(%{
13 colour: "some colour",
14 description: "some description",
15 name: "some tag"
16 })
17 |> Klepsidra.Categorisation.create_tag()
18
19 12 tag
20 end
21
22 @doc """
23 Generate a project_tag.
24 """
25 def project_tag_fixture(attrs \\ %{}) do
26 0 {:ok, project_tag} =
27 attrs
28 |> Enum.into(%{
29 project_id: 42,
30 tag_id: 42
31 })
32 |> Klepsidra.Categorisation.create_project_tag()
33
34 0 project_tag
35 end
36 end

test/support/fixtures/journals_fixtures.ex

50.0
4
20
2
Line Hits Source
0 defmodule Klepsidra.JournalsFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.Journals` context.
4 """
5
6 @doc """
7 Generate a journal_entry.
8 """
9 def journal_entry_fixture(attrs \\ %{}) do
10 0 {:ok, journal_entry} =
11 attrs
12 |> Enum.into(%{
13 journal_for: "2024-01-02",
14 entry_text_html: "some entry_text_html",
15 entry_text_markdown: "some entry_text_markdown",
16 is_private: true,
17 is_short_entry: true,
18 mood: "some mood"
19 })
20 |> Klepsidra.Journals.create_journal_entry()
21
22 0 journal_entry
23 end
24
25 @doc """
26 Generate a journal_entry_types.
27 """
28 def journal_entry_types_fixture(attrs \\ %{}) do
29 10 {:ok, journal_entry_types} =
30 attrs
31 |> Enum.into(%{
32 description: "some description",
33 name: "some name"
34 })
35 |> Klepsidra.Journals.create_journal_entry_types()
36
37 10 journal_entry_types
38 end
39 end

test/support/fixtures/localisation_fixtures.ex

100.0
2
12
0
Line Hits Source
0 defmodule Klepsidra.LocalisationFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.Localisation` context.
4 """
5
6 @doc """
7 Generate a language.
8 """
9 def language_fixture(attrs \\ %{}) do
10 6 {:ok, language} =
11 attrs
12 |> Enum.into(%{
13 "iso_639-1_language_code": "some iso_639-1",
14 "iso_639-2_language_code": "some iso_639-2",
15 "iso_639-3_language_code": "some iso_639-3",
16 language_name: "some language_name"
17 })
18 |> Klepsidra.Localisation.create_language()
19
20 6 language
21 end
22 end

test/support/fixtures/locations_fixtures.ex

28.5
14
24
10
Line Hits Source
0 defmodule Klepsidra.LocationsFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.Locations` context.
4 """
5
6 @doc """
7 Generate a feature_class.
8 """
9 def feature_class_fixture(attrs \\ %{}) do
10 6 {:ok, feature_class} =
11 attrs
12 |> Enum.into(%{
13 description: "some description",
14 feature_class: "P"
15 })
16 |> Klepsidra.Locations.create_feature_class()
17
18 6 feature_class
19 end
20
21 @doc """
22 Generate a feature_code.
23 """
24 def feature_code_fixture(attrs \\ %{}) do
25 0 {:ok, feature_code} =
26 attrs
27 |> Enum.into(%{
28 feature_code: "PPL",
29 feature_class: "P",
30 order: 42,
31 description: "some description",
32 note: "some note"
33 })
34 |> Klepsidra.Locations.create_feature_code()
35
36 0 feature_code
37 end
38
39 @doc """
40 Generate a continent.
41 """
42 def continent_fixture(attrs \\ %{}) do
43 6 {:ok, continent} =
44 attrs
45 |> Enum.into(%{
46 continent_code: "some continent_code",
47 continent_name: "some continent_name",
48 geoname_id: 42
49 })
50 |> Klepsidra.Locations.create_continent()
51
52 6 continent
53 end
54
55 @doc """
56 Generate a country.
57 """
58 def country_fixture(attrs \\ %{}) do
59 0 {:ok, country} =
60 attrs
61 |> Enum.into(%{
62 iso_country_code: "some iso",
63 iso_3_country_code: "some iso_3",
64 iso_numeric_country_code: 42,
65 country_name: "some country_name",
66 capital: "some capital",
67 population: 42,
68 area: 42,
69 continent_code: "some continent",
70 currency_code: "some currency_code",
71 currency_name: "some currency_name",
72 equivalent_fips_code: "some equivalent_fips_code",
73 fips: "some fips",
74 languages: "some languages",
75 neighbours: "some neighbours",
76 phone: "some phone",
77 postal_code_format: "some postal_code_format",
78 postal_code_regex: "some postal_code_regex",
79 tld: "some tld",
80 geoname_id: 42
81 })
82 |> Klepsidra.Locations.create_country()
83
84 0 country
85 end
86
87 @doc """
88 Generate a administrative_division1.
89 """
90 def administrative_division_1_fixture(attrs \\ %{}) do
91 0 {:ok, administrative_division_1} =
92 attrs
93 |> Enum.into(%{
94 administrative_division_1_code: "ENG",
95 country_code: "GB",
96 administrative_division_1_name: "some administrative_division_name",
97 administrative_division_1_name_ascii: "some administrative_division_name_ascii",
98 geoname_id: 42
99 })
100 |> Klepsidra.Locations.create_administrative_division_1()
101
102 0 administrative_division_1
103 end
104
105 @doc """
106 Generate a administrative_division2.
107 """
108 def administrative_division_2_fixture(attrs \\ %{}) do
109 0 {:ok, administrative_division_2} =
110 attrs
111 |> Enum.into(%{
112 administrative_division_2_code: "some administrative_division2_code",
113 administrative_division_1_code: "some administrative_division1_code",
114 country_code: "GB",
115 administrative_division_2_ascii_name: "some administrative_division_ascii_name",
116 administrative_division_2_name: "some administrative_division_name",
117 geoname_id: 42
118 })
119 |> Klepsidra.Locations.create_administrative_division_2()
120
121 0 administrative_division_2
122 end
123
124 @doc """
125 Generate a city.
126 """
127 def city_fixture(attrs \\ %{}) do
128 0 {:ok, city} =
129 attrs
130 |> Enum.into(%{
131 geoname_id: 42,
132 name: "some name",
133 alternatenames: "some alternatenames",
134 ascii_name: "some asciiname",
135 country_code: "some country_code",
136 cc2: "some cc2",
137 feature_class: "P",
138 feature_code: "PPL",
139 latitude: 120.5,
140 longitude: 120.5,
141 administrative_division_1_code: "some admin1_code",
142 administrative_division_2_code: "some admin2_code",
143 administrative_division_3_code: "some admin3_code",
144 administrative_division_4_code: "some admin4_code",
145 population: 42,
146 elevation: 42,
147 dem: 42,
148 timezone: "some timezone",
149 modification_date: ~D[2024-10-08]
150 })
151 |> Klepsidra.Locations.create_city()
152
153 0 city
154 end
155 end

test/support/fixtures/project_fixtures.ex

0.0
0
0
0
Line Hits Source
0 defmodule Klepsidra.ProjectFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.Project` context.
4 """
5
6 # @doc """
7 # Generate a note.
8 # """
9 # def note_fixture(attrs \\ %{}) do
10 # {:ok, note} =
11 # attrs
12 # |> Enum.into(%{
13 # note: "some note",
14 # project_id: 42,
15 # user_id: 42
16 # })
17 # |> Klepsidra.Project.create_note()
18
19 # note
20 # end
21 end

test/support/fixtures/projects_fixtures.ex

50.0
4
24
2
Line Hits Source
0 defmodule Klepsidra.ProjectsFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.Projects` context.
4 """
5
6 @doc """
7 Generate a project.
8 """
9 def project_fixture(attrs \\ %{}) do
10 12 {:ok, project} =
11 attrs
12 |> Enum.into(%{
13 active: true,
14 description: "some description",
15 name: "some name"
16 })
17 |> Klepsidra.Projects.create_project()
18
19 12 project
20 end
21
22 @doc """
23 Generate a note.
24 """
25 def note_fixture(attrs \\ %{}) do
26 0 {:ok, note} =
27 attrs
28 |> Enum.into(%{
29 note: "some note",
30 project_id: 42,
31 user_id: 42
32 })
33 |> Klepsidra.Projects.create_note()
34
35 0 note
36 end
37 end

test/support/fixtures/time_tracking_fixtures.ex

66.6
6
46
2
Line Hits Source
0 defmodule Klepsidra.TimeTrackingFixtures do
1 @moduledoc """
2 This module defines test helpers for creating
3 entities via the `Klepsidra.TimeTracking` context.
4 """
5
6 @doc """
7 Generate a timer.
8 """
9 def timer_fixture(attrs \\ %{}) do
10 11 {:ok, timer} =
11 attrs
12 |> Enum.into(%{
13 description: "some description",
14 duration: 42,
15 duration_time_unit: "minute",
16 end_stamp: "2024-12-09 12:34:56",
17 billing_duration: 42,
18 billing_duration_time_unit: "minute",
19 start_stamp: "2024-12-09 12:30",
20 billing_rate: "0"
21 })
22 |> Klepsidra.TimeTracking.create_timer()
23
24 11 timer
25 end
26
27 @doc """
28 Generate a note.
29 """
30 def note_fixture(attrs \\ %{}) do
31 0 {:ok, note} =
32 attrs
33 |> Enum.into(%{
34 note: "some note"
35 })
36 |> Klepsidra.TimeTracking.create_note()
37
38 0 note
39 end
40
41 @doc """
42 Generate a activity_type.
43 """
44 def activity_type_fixture(attrs \\ %{}) do
45 12 {:ok, activity_type} =
46 attrs
47 |> Enum.into(%{
48 active: true,
49 name: "some activity_type",
50 billing_rate: "120.5"
51 })
52 |> Klepsidra.TimeTracking.create_activity_type()
53
54 12 activity_type
55 end
56 end